mirror of
https://github.com/zadam/trilium.git
synced 2025-12-15 12:49:53 +01:00
Compare commits
247 Commits
v0.100.0
...
bugfix/tit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5907b7090e | ||
|
|
6570a55e7e | ||
|
|
f4838bb3b5 | ||
|
|
edb2a65196 | ||
|
|
fd721cac51 | ||
|
|
a1ff3cc8f7 | ||
|
|
3fa6b264e5 | ||
|
|
a6682be251 | ||
|
|
baee2cd6b2 | ||
|
|
3ee8eac635 | ||
|
|
ce5a775160 | ||
|
|
2cf66d1c53 | ||
|
|
eaee67d742 | ||
|
|
c681496b1b | ||
|
|
829d3e046d | ||
|
|
5c8df540db | ||
|
|
8920e6e448 | ||
|
|
3cb860232e | ||
|
|
d588518ba1 | ||
|
|
d6f727d17a | ||
|
|
82c40302bd | ||
|
|
c40c62a247 | ||
|
|
8048b5ebca | ||
|
|
03ba43df5d | ||
|
|
a4b95b45ec | ||
|
|
59513962fe | ||
|
|
a201b43cde | ||
|
|
f98c77bd16 | ||
|
|
2596359b25 | ||
|
|
e0a0263607 | ||
|
|
2d3feedb07 | ||
|
|
af392fad3d | ||
|
|
ef74490c44 | ||
|
|
b6856e18a8 | ||
|
|
de1d4424d9 | ||
|
|
5ec45bb575 | ||
|
|
cb3aced2ed | ||
|
|
2f13a1ad21 | ||
|
|
045127adee | ||
|
|
db1a0c0362 | ||
|
|
dcaf91a878 | ||
|
|
a9209f5103 | ||
|
|
ea613986c2 | ||
|
|
1ed46bd47c | ||
|
|
f1ee79e75a | ||
|
|
cd27160905 | ||
|
|
9ddf4a1308 | ||
|
|
a1c5ed9eb5 | ||
|
|
7a4f19eada | ||
|
|
397fb785d6 | ||
|
|
75a1fcc933 | ||
|
|
292cbf1383 | ||
|
|
37a14fefb3 | ||
|
|
f424633d8c | ||
|
|
048258d2d1 | ||
|
|
f779108b6c | ||
|
|
522f3ae0a1 | ||
|
|
3fc7067c59 | ||
|
|
c600e8ef89 | ||
|
|
5ad267fe1b | ||
|
|
d8b3e438f8 | ||
|
|
2834af66e9 | ||
|
|
1bbf86fbeb | ||
|
|
f662b95dc9 | ||
|
|
9ad4b725ac | ||
|
|
0182c61aec | ||
|
|
78362535c7 | ||
|
|
ae7b31f343 | ||
|
|
f16441bba4 | ||
|
|
d3f9bb6def | ||
|
|
e02440aa59 | ||
|
|
f1d87c29d3 | ||
|
|
21335b1b00 | ||
|
|
7463570e76 | ||
|
|
da17a63ef5 | ||
|
|
eb8f2021cb | ||
|
|
888ff33be1 | ||
|
|
b46850e86e | ||
|
|
f053587f09 | ||
|
|
7a3092a23b | ||
|
|
d95450ae07 | ||
|
|
230def10fe | ||
|
|
036f8e49a4 | ||
|
|
4eca8a5640 | ||
|
|
08f96a91f3 | ||
|
|
2e915eccd6 | ||
|
|
c05c58c82b | ||
|
|
572feed918 | ||
|
|
d30d207ab5 | ||
|
|
2a6b91dd04 | ||
|
|
3dee1725b3 | ||
|
|
f7ac465e67 | ||
|
|
912f14549c | ||
|
|
21079335e7 | ||
|
|
85741240f1 | ||
|
|
3df9a87b29 | ||
|
|
acd60007ac | ||
|
|
f66c9630e3 | ||
|
|
50a5892a31 | ||
|
|
c5f58437b1 | ||
|
|
a915424d9a | ||
|
|
be7699c600 | ||
|
|
a7c946ddae | ||
|
|
4b741a9434 | ||
|
|
cd963272d4 | ||
|
|
9de17ead91 | ||
|
|
d98b133d63 | ||
|
|
361e8e0066 | ||
|
|
523c44b796 | ||
|
|
4bd60ed6a1 | ||
|
|
7d57f08baf | ||
|
|
5e7f54dbc3 | ||
|
|
dfb9ce990d | ||
|
|
e87a368e87 | ||
|
|
44506057fd | ||
|
|
ff08eadb23 | ||
|
|
ebb1a3feb2 | ||
|
|
81bc85b8e4 | ||
|
|
4a749f52e9 | ||
|
|
25e37ddd78 | ||
|
|
46a9cfcc67 | ||
|
|
66ff944660 | ||
|
|
988db59197 | ||
|
|
34e321407a | ||
|
|
44c0028a51 | ||
|
|
264b75cd68 | ||
|
|
cf9ccdcab6 | ||
|
|
48e18e533c | ||
|
|
12ef778bf4 | ||
|
|
6c31a1788a | ||
|
|
aa7c6da8ef | ||
|
|
1ff40cace0 | ||
|
|
0a1dadbea1 | ||
|
|
24edbdba5e | ||
|
|
db504eff88 | ||
|
|
5e09925659 | ||
|
|
d1b2b351c8 | ||
|
|
50b0dc178e | ||
|
|
b34118e395 | ||
|
|
e6436f9021 | ||
|
|
831d1b4f3a | ||
|
|
0456d1ca29 | ||
|
|
fcd151022e | ||
|
|
0c1998002e | ||
|
|
b8c33ce7fa | ||
|
|
454cd633e8 | ||
|
|
53cb9a6e10 | ||
|
|
8d6ff763d6 | ||
|
|
ecf15af3a0 | ||
|
|
716823789d | ||
|
|
3c192badce | ||
|
|
d94b611d10 | ||
|
|
e2ce329b6c | ||
|
|
ef902fc706 | ||
|
|
a7fc94c303 | ||
|
|
c8c6d1bb1e | ||
|
|
a205108681 | ||
|
|
31561879b3 | ||
|
|
fdb6677153 | ||
|
|
17241be4bc | ||
|
|
3bf6de9c76 | ||
|
|
a53322e7cb | ||
|
|
a107c126e4 | ||
|
|
3a8dcae53a | ||
|
|
b99d4532df | ||
|
|
6e8f8ea357 | ||
|
|
83838bbe76 | ||
|
|
66620aabe2 | ||
|
|
74fcf8270d | ||
|
|
91b4e32a38 | ||
|
|
3f0c114f24 | ||
|
|
30fe6b93c4 | ||
|
|
ec99242314 | ||
|
|
9149fb7a85 | ||
|
|
3c919d9a8a | ||
|
|
22f9ce1e2e | ||
|
|
72b01cec70 | ||
|
|
57b8bc2645 | ||
|
|
3ad4ca3943 | ||
|
|
237ffeff52 | ||
|
|
fb491d9790 | ||
|
|
facd03b6ad | ||
|
|
f5f38ca670 | ||
|
|
83e599f0e9 | ||
|
|
48cd06f37e | ||
|
|
aac9d2d1c4 | ||
|
|
7e2e1c12b9 | ||
|
|
3410dd4eba | ||
|
|
d511085db3 | ||
|
|
caaa3583a7 | ||
|
|
185e5691a4 | ||
|
|
20f44cc64f | ||
|
|
07ef94afd9 | ||
|
|
2d33b8a958 | ||
|
|
d283f5dbb4 | ||
|
|
1af76c4d06 | ||
|
|
07498c6bef | ||
|
|
18f9ebbc4f | ||
|
|
85b4f652f4 | ||
|
|
eec6f7336c | ||
|
|
f976dd8d30 | ||
|
|
2d3aa3a96e | ||
|
|
1195cbd772 | ||
|
|
cebfa674ef | ||
|
|
3ed596496d | ||
|
|
d99ef78348 | ||
|
|
2666c1e196 | ||
|
|
4b8c8888ee | ||
|
|
ce1fd64aa9 | ||
|
|
bd1479b14a | ||
|
|
baee9520d1 | ||
|
|
adb30a526e | ||
|
|
60c40457fc | ||
|
|
a6df457c9c | ||
|
|
3fd8fb0308 | ||
|
|
7d4a7d4ab6 | ||
|
|
4571b95683 | ||
|
|
a65d2a1bba | ||
|
|
5c9503732d | ||
|
|
2dbbf7f350 | ||
|
|
e1cce220b3 | ||
|
|
e0aed26f63 | ||
|
|
62fd07258e | ||
|
|
0d8127140f | ||
|
|
bab5326d7c | ||
|
|
5c8445f3fe | ||
|
|
604488b166 | ||
|
|
b7d7fc8b67 | ||
|
|
1f4872f72b | ||
|
|
f89c40cde6 | ||
|
|
670edbc22a | ||
|
|
54f70c8158 | ||
|
|
0d6bcba023 | ||
|
|
06a9f95979 | ||
|
|
03cdfc259e | ||
|
|
1963b5732a | ||
|
|
1e05dc937c | ||
|
|
c3b22ff737 | ||
|
|
991f07e148 | ||
|
|
8efb849391 | ||
|
|
5b310f3e46 | ||
|
|
5f54e42a43 | ||
|
|
a83f20e454 | ||
|
|
cdab86bd83 | ||
|
|
48cbb80e79 | ||
|
|
1af6200655 | ||
|
|
b8585594cd |
@@ -1,6 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{js,ts,tsx}]
|
||||
[*.{js,ts,tsx,css}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
|
||||
4
.github/actions/build-electron/action.yml
vendored
4
.github/actions/build-electron/action.yml
vendored
@@ -21,7 +21,7 @@ runs:
|
||||
# Certificate setup
|
||||
- name: Import Apple certificates
|
||||
if: inputs.os == 'macos'
|
||||
uses: apple-actions/import-codesign-certs@v5
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
with:
|
||||
p12-file-base64: ${{ env.APPLE_APP_CERTIFICATE_BASE64 }}
|
||||
p12-password: ${{ env.APPLE_APP_CERTIFICATE_PASSWORD }}
|
||||
@@ -30,7 +30,7 @@ runs:
|
||||
|
||||
- name: Install Installer certificate
|
||||
if: inputs.os == 'macos'
|
||||
uses: apple-actions/import-codesign-certs@v5
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
with:
|
||||
p12-file-base64: ${{ env.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
|
||||
p12-password: ${{ env.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
|
||||
|
||||
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.4.2
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.4.2
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -127,7 +127,7 @@ jobs:
|
||||
path: upload
|
||||
|
||||
- name: Publish stable release
|
||||
uses: softprops/action-gh-release@v2.4.2
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
with:
|
||||
draft: false
|
||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,3 +49,4 @@ upload
|
||||
|
||||
# docs
|
||||
site/
|
||||
apps/*/coverage
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -9,7 +9,6 @@
|
||||
"tobermory.es6-string-html",
|
||||
"vitest.explorer",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
"usernamehw.errorlens"
|
||||
]
|
||||
}
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -36,5 +36,8 @@
|
||||
"docs/**/*.png": true,
|
||||
"apps/server/src/assets/doc_notes/**": true,
|
||||
"apps/edit-docs/demo/**": true
|
||||
}
|
||||
},
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "*", "severity": "warn" }
|
||||
]
|
||||
}
|
||||
63
README.md
63
README.md
@@ -16,13 +16,14 @@
|
||||

|
||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
[English](./README.md) | [Chinese (Simplified)](./docs/README-ZH_CN.md) | [Chinese (Traditional)](./docs/README-ZH_TW.md) | [Russian](./docs/README-ru.md) | [Japanese](./docs/README-ja.md) | [Italian](./docs/README-it.md) | [Spanish](./docs/README-es.md)
|
||||
<!-- translate:off -->
|
||||
<!-- LANGUAGE SWITCHER -->
|
||||
[Chinese (Simplified Han script)](./docs/README-ZH_CN.md) | [Chinese (Traditional Han script)](./docs/README-ZH_TW.md) | [English](./docs/README.md) | [French](./docs/README-fr.md) | [German](./docs/README-de.md) | [Greek](./docs/README-el.md) | [Italian](./docs/README-it.md) | [Japanese](./docs/README-ja.md) | [Romanian](./docs/README-ro.md) | [Spanish](./docs/README-es.md)
|
||||
<!-- translate:on -->
|
||||
|
||||
Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
|
||||
|
||||
See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for quick overview:
|
||||
|
||||
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
|
||||
<img src="./docs/app.png" alt="Trilium Screenshot" width="1000">
|
||||
|
||||
## ⏬ Download
|
||||
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) – stable version, recommended for most users.
|
||||
@@ -39,39 +40,39 @@ Our documentation is available in multiple formats:
|
||||
|
||||
### Quick Links
|
||||
- [Getting Started Guide](https://docs.triliumnotes.org/)
|
||||
- [Installation Instructions](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
|
||||
- [Docker Setup](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
|
||||
- [Upgrading TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
|
||||
- [Basic Concepts and Features](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
|
||||
- [Patterns of Personal Knowledge Base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
|
||||
- [Installation Instructions](https://docs.triliumnotes.org/user-guide/setup)
|
||||
- [Docker Setup](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
|
||||
- [Upgrading TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
|
||||
- [Basic Concepts and Features](https://docs.triliumnotes.org/user-guide/concepts/notes)
|
||||
- [Patterns of Personal Knowledge Base](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
|
||||
|
||||
## 🎁 Features
|
||||
|
||||
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://triliumnext.github.io/Docs/Wiki/cloning-notes))
|
||||
* Rich WYSIWYG note editor including e.g. tables, images and [math](https://triliumnext.github.io/Docs/Wiki/text-notes) with markdown [autoformat](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)
|
||||
* Support for editing [notes with source code](https://triliumnext.github.io/Docs/Wiki/code-notes), including syntax highlighting
|
||||
* Fast and easy [navigation between notes](https://triliumnext.github.io/Docs/Wiki/note-navigation), full text search and [note hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
|
||||
* Seamless [note versioning](https://triliumnext.github.io/Docs/Wiki/note-revisions)
|
||||
* Note [attributes](https://triliumnext.github.io/Docs/Wiki/attributes) can be used for note organization, querying and advanced [scripting](https://triliumnext.github.io/Docs/Wiki/scripts)
|
||||
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
|
||||
* Rich WYSIWYG note editor including e.g. tables, images and [math](https://docs.triliumnotes.org/user-guide/note-types/text) with markdown [autoformat](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
|
||||
* Support for editing [notes with source code](https://docs.triliumnotes.org/user-guide/note-types/code), including syntax highlighting
|
||||
* Fast and easy [navigation between notes](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-navigation), full text search and [note hoisting](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-hoisting)
|
||||
* Seamless [note versioning](https://docs.triliumnotes.org/user-guide/concepts/notes/note-revisions)
|
||||
* Note [attributes](https://docs.triliumnotes.org/user-guide/advanced-usage/attributes) can be used for note organization, querying and advanced [scripting](https://docs.triliumnotes.org/user-guide/scripts)
|
||||
* UI available in English, German, Spanish, French, Romanian, and Chinese (simplified and traditional)
|
||||
* Direct [OpenID and TOTP integration](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md) for more secure login
|
||||
* [Synchronization](https://triliumnext.github.io/Docs/Wiki/synchronization) with self-hosted sync server
|
||||
* there's a [3rd party service for hosting synchronisation server](https://trilium.cc/paid-hosting)
|
||||
* [Sharing](https://triliumnext.github.io/Docs/Wiki/sharing) (publishing) notes to public internet
|
||||
* Strong [note encryption](https://triliumnext.github.io/Docs/Wiki/protected-notes) with per-note granularity
|
||||
* Direct [OpenID and TOTP integration](https://docs.triliumnotes.org/user-guide/setup/server/mfa) for more secure login
|
||||
* [Synchronization](https://docs.triliumnotes.org/user-guide/setup/synchronization) with self-hosted sync server
|
||||
* there are [3rd party services for hosting synchronisation server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
|
||||
* [Sharing](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing) (publishing) notes to public internet
|
||||
* Strong [note encryption](https://docs.triliumnotes.org/user-guide/concepts/notes/protected-notes) with per-note granularity
|
||||
* Sketching diagrams, based on [Excalidraw](https://excalidraw.com/) (note type "canvas")
|
||||
* [Relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map) and [link maps](https://triliumnext.github.io/Docs/Wiki/link-map) for visualizing notes and their relations
|
||||
* [Relation maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and [note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map) for visualizing notes and their relations
|
||||
* Mind maps, based on [Mind Elixir](https://docs.mind-elixir.com/)
|
||||
* [Geo maps](./docs/User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md) with location pins and GPX tracks
|
||||
* [Scripting](https://triliumnext.github.io/Docs/Wiki/scripts) - see [Advanced showcases](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
|
||||
* [REST API](https://triliumnext.github.io/Docs/Wiki/etapi) for automation
|
||||
* [Geo maps](https://docs.triliumnotes.org/user-guide/collections/geomap) with location pins and GPX tracks
|
||||
* [Scripting](https://docs.triliumnotes.org/user-guide/scripts) - see [Advanced showcases](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
|
||||
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) for automation
|
||||
* Scales well in both usability and performance upwards of 100 000 notes
|
||||
* Touch optimized [mobile frontend](https://triliumnext.github.io/Docs/Wiki/mobile-frontend) for smartphones and tablets
|
||||
* Built-in [dark theme](https://triliumnext.github.io/Docs/Wiki/themes), support for user themes
|
||||
* [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and [Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown)
|
||||
* [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content
|
||||
* Touch optimized [mobile frontend](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) for smartphones and tablets
|
||||
* Built-in [dark theme](https://docs.triliumnotes.org/user-guide/concepts/themes), support for user themes
|
||||
* [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote) and [Markdown import & export](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
|
||||
* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) for easy saving of web content
|
||||
* Customizable UI (sidebar buttons, user-defined widgets, ...)
|
||||
* [Metrics](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md), along with a [Grafana Dashboard](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)
|
||||
* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics), along with a Grafana Dashboard.
|
||||
|
||||
✨ Check out the following third-party resources/communities for more TriliumNext related goodies:
|
||||
|
||||
@@ -131,7 +132,7 @@ Note: It is best to disable automatic updates on your server installation (see b
|
||||
|
||||
### Server
|
||||
|
||||
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
|
||||
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server installation docs](https://docs.triliumnotes.org/user-guide/setup/server).
|
||||
|
||||
|
||||
## 💻 Contribute
|
||||
@@ -198,7 +199,7 @@ Trilium would not be possible without the technologies behind it:
|
||||
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical maps.
|
||||
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive table used in collections.
|
||||
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library without real competition.
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library. Used in [relation maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and [link maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
|
||||
|
||||
## 🤝 Support
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import anonymizationService from "../src/services/anonymization.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
fs.writeFileSync(path.resolve(__dirname, "tpl", "anonymize-database.sql"), anonymizationService.getFullAnonymizationScript());
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCHEMA_FILE_PATH=db/schema.sql
|
||||
|
||||
sqlite3 ./data/document.db .schema | grep -v "sqlite_sequence" > "$SCHEMA_FILE_PATH"
|
||||
|
||||
echo "DB schema exported to $SCHEMA_FILE_PATH"
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ $# -eq 0 ]] ; then
|
||||
echo "Missing argument of new version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
SERIES=${VERSION:0:4}-latest
|
||||
|
||||
docker push zadam/trilium:$VERSION
|
||||
docker push zadam/trilium:$SERIES
|
||||
|
||||
if [[ $1 != *"beta"* ]]; then
|
||||
docker push zadam/trilium:latest
|
||||
fi
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ $# -eq 0 ]] ; then
|
||||
echo "Missing argument of new version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
|
||||
if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ;
|
||||
then
|
||||
echo "Version ${VERSION} isn't in format X.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION_DATE=$(git log -1 --format=%aI "v${VERSION}" | cut -c -10)
|
||||
VERSION_COMMIT=$(git rev-list -n 1 "v${VERSION}")
|
||||
|
||||
# expecting the directory at a specific path
|
||||
cd ~/trilium-flathub || exit
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "There are uncommitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE_BRANCH=main
|
||||
|
||||
if [[ "$VERSION" == *"beta"* ]]; then
|
||||
BASE_BRANCH=beta
|
||||
fi
|
||||
|
||||
git switch "${BASE_BRANCH}"
|
||||
git pull
|
||||
|
||||
BRANCH=b${VERSION}
|
||||
|
||||
git branch "${BRANCH}"
|
||||
git switch "${BRANCH}"
|
||||
|
||||
echo "Updating files with version ${VERSION}, date ${VERSION_DATE} and commit ${VERSION_COMMIT}"
|
||||
|
||||
flatpak-node-generator npm ../trilium/package-lock.json
|
||||
|
||||
xmlstarlet ed --inplace --update "/component/releases/release/@version" --value "${VERSION}" --update "/component/releases/release/@date" --value "${VERSION_DATE}" ./com.github.zadam.trilium.metainfo.xml
|
||||
|
||||
yq --inplace "(.modules[0].sources[0].tag = \"v${VERSION}\") | (.modules[0].sources[0].commit = \"${VERSION_COMMIT}\")" ./com.github.zadam.trilium.yml
|
||||
|
||||
git add ./generated-sources.json
|
||||
git add ./com.github.zadam.trilium.metainfo.xml
|
||||
git add ./com.github.zadam.trilium.yml
|
||||
|
||||
git commit -m "release $VERSION"
|
||||
git push --set-upstream origin "${BRANCH}"
|
||||
|
||||
gh pr create --fill -B "${BASE_BRANCH}"
|
||||
gh pr merge --auto --merge --delete-branch
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ $# -eq 0 ]] ; then
|
||||
echo "Missing argument of new version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "Missing command: jq"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
|
||||
if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ;
|
||||
then
|
||||
echo "Version ${VERSION} isn't in format X.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "There are uncommitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Releasing Trilium $VERSION"
|
||||
|
||||
jq '.version = "'$VERSION'"' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
git add package.json
|
||||
|
||||
npm run chore:update-build-info
|
||||
|
||||
git add src/services/build.ts
|
||||
|
||||
TAG=v$VERSION
|
||||
|
||||
echo "Committing package.json version change"
|
||||
|
||||
git commit -m "chore(release): $VERSION"
|
||||
git push
|
||||
|
||||
echo "Tagging commit with $TAG"
|
||||
|
||||
git tag $TAG
|
||||
git push origin $TAG
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,51 +0,0 @@
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
// consider using rules below, once we have a full TS codebase and can be more strict
|
||||
// tseslint.configs.strictTypeChecked,
|
||||
// tseslint.configs.stylisticTypeChecked,
|
||||
// tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
"simple-import-sort": simpleImportSort
|
||||
}
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
// add rule overrides here
|
||||
"no-undef": "off",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_"
|
||||
}
|
||||
],
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"build/*",
|
||||
"dist/*",
|
||||
"docs/*",
|
||||
"demo/*",
|
||||
"src/public/app-dist/*",
|
||||
"src/public/app/doc_notes/*"
|
||||
]
|
||||
}
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
|
||||
// eslint config just for formatting rules
|
||||
// potentially to be merged with the linting rules into one single config,
|
||||
// once we have fixed the majority of lint errors
|
||||
|
||||
// Go to https://eslint.style/rules/default/${rule_without_prefix} to check the rule details
|
||||
export const stylisticRules = {
|
||||
"@stylistic/indent": [ "error", 4 ],
|
||||
"@stylistic/quotes": [ "error", "double", { avoidEscape: true, allowTemplateLiterals: "always" } ],
|
||||
"@stylistic/semi": [ "error", "always" ],
|
||||
"@stylistic/quote-props": [ "error", "consistent-as-needed" ],
|
||||
"@stylistic/max-len": [ "error", { code: 100 } ],
|
||||
"@stylistic/comma-dangle": [ "error", "never" ],
|
||||
"@stylistic/linebreak-style": [ "error", "unix" ],
|
||||
"@stylistic/array-bracket-spacing": [ "error", "always" ],
|
||||
"@stylistic/object-curly-spacing": [ "error", "always" ],
|
||||
"@stylistic/padded-blocks": [ "error", { classes: "always" } ]
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
files: [ "**/*.{js,ts,mjs,cjs}" ],
|
||||
languageOptions: {
|
||||
parser: tsParser
|
||||
},
|
||||
plugins: {
|
||||
"@stylistic": stylistic
|
||||
},
|
||||
rules: {
|
||||
...stylisticRules
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"build/*",
|
||||
"dist/*",
|
||||
"docs/*",
|
||||
"demo/*",
|
||||
// TriliumNextTODO: check if we want to format packages here as well - for now skipping it
|
||||
"packages/*",
|
||||
"src/public/app-dist/*",
|
||||
"src/public/app/doc_notes/*"
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -1,17 +0,0 @@
|
||||
import { test as setup, expect } from "@playwright/test";
|
||||
|
||||
const authFile = "playwright/.auth/user.json";
|
||||
|
||||
const ROOT_URL = "http://localhost:8082";
|
||||
const LOGIN_PASSWORD = "demo1234";
|
||||
|
||||
// Reference: https://playwright.dev/docs/auth#basic-shared-account-in-all-tests
|
||||
|
||||
setup("authenticate", async ({ page }) => {
|
||||
await page.goto(ROOT_URL);
|
||||
await expect(page).toHaveURL(`${ROOT_URL}/login`);
|
||||
|
||||
await page.getByRole("textbox", { name: "Password" }).fill(LOGIN_PASSWORD);
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("Can duplicate note with broken links", async ({ page }) => {
|
||||
await page.goto(`http://localhost:8082/#2VammGGdG6Ie`);
|
||||
await page.locator(".tree-wrapper .fancytree-active").getByText("Note map").click({ button: "right" });
|
||||
await page.getByText("Duplicate subtree").click();
|
||||
await expect(page.locator(".toast-body")).toBeHidden();
|
||||
await expect(page.locator(".tree-wrapper").getByText("Note map (dup)")).toBeVisible();
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("has title", async ({ page }) => {
|
||||
await page.goto("https://playwright.dev/");
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/Playwright/);
|
||||
});
|
||||
|
||||
test("get started link", async ({ page }) => {
|
||||
await page.goto("https://playwright.dev/");
|
||||
|
||||
// Click the get started link.
|
||||
await page.getByRole("link", { name: "Get started" }).click();
|
||||
|
||||
// Expects page to have a heading with the name of Installation.
|
||||
await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible();
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
|
||||
test("Native Title Bar not displayed on web", async ({ page }) => {
|
||||
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsAppearance");
|
||||
await expect(page.getByRole("heading", { name: "Theme" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Native Title Bar (requires" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("Tray settings not displayed on web", async ({ page }) => {
|
||||
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsOther");
|
||||
await expect(page.getByRole("heading", { name: "Note Erasure Timeout" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Tray" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("Spellcheck settings not displayed on web", async ({ page }) => {
|
||||
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsSpellcheck");
|
||||
await expect(page.getByRole("heading", { name: "Spell Check" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Tray" })).toBeHidden();
|
||||
await expect(page.getByText("These options apply only for desktop builds")).toBeVisible();
|
||||
await expect(page.getByText("Enable spellcheck")).toBeHidden();
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
|
||||
test("Renders on desktop", async ({ page, context }) => {
|
||||
await page.goto("http://localhost:8082");
|
||||
await expect(page.locator(".tree")).toContainText("Trilium Integration Test");
|
||||
});
|
||||
|
||||
test("Renders on mobile", async ({ page, context }) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
url: "http://localhost:8082",
|
||||
name: "trilium-device",
|
||||
value: "mobile"
|
||||
}
|
||||
]);
|
||||
await page.goto("http://localhost:8082");
|
||||
await expect(page.locator(".tree")).toContainText("Trilium Integration Test");
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const expectedVersion = "0.90.3";
|
||||
|
||||
test("Displays update badge when there is a version available", async ({ page }) => {
|
||||
await page.goto("http://localhost:8080");
|
||||
await page.getByRole("button", { name: "" }).click();
|
||||
await page.getByText(`Version ${expectedVersion} is available,`).click();
|
||||
|
||||
const page1 = await page.waitForEvent("popup");
|
||||
expect(page1.url()).toBe(`https://github.com/TriliumNext/Trilium/releases/tag/v${expectedVersion}`);
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"main": "./electron-main.js",
|
||||
"bin": {
|
||||
"trilium": "src/main.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server:start-safe": "cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nodemon src/main.ts",
|
||||
"server:start-no-dir": "cross-env TRILIUM_ENV=dev nodemon src/main.ts",
|
||||
"server:start-test": "npm run server:switch && rimraf ./data-test && cross-env TRILIUM_DATA_DIR=./data-test TRILIUM_ENV=dev TRILIUM_PORT=9999 nodemon src/main.ts",
|
||||
"server:qstart": "npm run server:switch && npm run server:start",
|
||||
"server:switch": "rimraf ./node_modules/better-sqlite3 && npm install",
|
||||
"electron:start-no-dir": "cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_ENV=dev TRILIUM_PORT=37742 electron --inspect=5858 .",
|
||||
"electron:start-nix": "electron-rebuild --version 33.3.1 && cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./electron-main.ts --inspect=5858 .\"",
|
||||
"electron:start-nix-no-dir": "electron-rebuild --version 33.3.1 && cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_ENV=dev TRILIUM_PORT=37742 nix-shell -p electron_33 --run \"electron ./electron-main.ts --inspect=5858 .\"",
|
||||
"electron:start-prod-no-dir": "npm run build:prepare-dist && cross-env TRILIUM_ENV=prod electron --inspect=5858 .",
|
||||
"electron:start-prod-nix": "electron-rebuild --version 33.3.1 && npm run build:prepare-dist && cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./dist/electron-main.js --inspect=5858 .\"",
|
||||
"electron:start-prod-nix-no-dir": "electron-rebuild --version 33.3.1 && npm run build:prepare-dist && cross-env TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./dist/electron-main.js --inspect=5858 .\"",
|
||||
"electron:qstart": "npm run electron:switch && npm run electron:start",
|
||||
"electron:switch": "electron-rebuild",
|
||||
"docs:build": "typedoc",
|
||||
"test": "npm run client:test && npm run server:test",
|
||||
"client:test": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db TRILIUM_INTEGRATION_TEST=memory vitest --root src/public/app",
|
||||
"client:coverage": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db TRILIUM_INTEGRATION_TEST=memory vitest --root src/public/app --coverage",
|
||||
"test:playwright": "playwright test --workers 1",
|
||||
"test:integration-edit-db": "cross-env TRILIUM_INTEGRATION_TEST=edit TRILIUM_PORT=8081 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
|
||||
"test:integration-mem-db": "cross-env nodemon src/main.ts",
|
||||
"test:integration-mem-db-dev": "cross-env TRILIUM_INTEGRATION_TEST=memory TRILIUM_PORT=8082 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
|
||||
"dev:watch-dist": "tsx ./bin/watch-dist.ts",
|
||||
"dev:format-check": "eslint -c eslint.format.config.js .",
|
||||
"dev:format-fix": "eslint -c eslint.format.config.js . --fix",
|
||||
"dev:linter-check": "eslint .",
|
||||
"dev:linter-fix": "eslint . --fix",
|
||||
"chore:generate-document": "cross-env nodemon ./bin/generate_document.ts 1000",
|
||||
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.57.0",
|
||||
"@stylistic/eslint-plugin": "5.6.1",
|
||||
"@types/express": "5.0.5",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/yargs": "17.0.35",
|
||||
"@vitest/coverage-v8": "4.0.14",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"esm": "3.2.25",
|
||||
"jsdoc": "4.0.5",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"rcedit": "5.0.2",
|
||||
"rimraf": "6.1.2",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"appdmg": "0.6.6"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import etapi from "../support/etapi.js";
|
||||
/* TriliumNextTODO: port to Vitest
|
||||
etapi.describeEtapi("app_info", () => {
|
||||
it("get", async () => {
|
||||
const appInfo = await etapi.getEtapi("app-info");
|
||||
expect(appInfo.clipperProtocolVersion).toEqual("1.0");
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -1,10 +0,0 @@
|
||||
import etapi from "../support/etapi.js";
|
||||
|
||||
/* TriliumNextTODO: port to Vitest
|
||||
etapi.describeEtapi("backup", () => {
|
||||
it("create", async () => {
|
||||
const response = await etapi.putEtapiContent("backup/etapi_test");
|
||||
expect(response.status).toEqual(204);
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -1,26 +0,0 @@
|
||||
import etapi from "../support/etapi.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
/* TriliumNextTODO: port to Vitest
|
||||
etapi.describeEtapi("import", () => {
|
||||
// temporarily skip this test since test-export.zip is missing
|
||||
xit("import", async () => {
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const zipFileBuffer = fs.readFileSync(path.resolve(scriptDir, "test-export.zip"));
|
||||
|
||||
const response = await etapi.postEtapiContent("notes/root/import", zipFileBuffer);
|
||||
expect(response.status).toEqual(201);
|
||||
|
||||
const { note, branch } = await response.json();
|
||||
|
||||
expect(note.title).toEqual("test-export");
|
||||
expect(branch.parentNoteId).toEqual("root");
|
||||
|
||||
const content = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
|
||||
expect(content).toContain("test export content");
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -1,103 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import etapi from "../support/etapi.js";
|
||||
|
||||
/* TriliumNextTODO: port to Vitest
|
||||
etapi.describeEtapi("notes", () => {
|
||||
it("create", async () => {
|
||||
const { note, branch } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "text",
|
||||
title: "Hello World!",
|
||||
content: "Content",
|
||||
prefix: "Custom prefix"
|
||||
});
|
||||
|
||||
expect(note.title).toEqual("Hello World!");
|
||||
expect(branch.parentNoteId).toEqual("root");
|
||||
expect(branch.prefix).toEqual("Custom prefix");
|
||||
|
||||
const rNote = await etapi.getEtapi(`notes/${note.noteId}`);
|
||||
expect(rNote.title).toEqual("Hello World!");
|
||||
|
||||
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
|
||||
expect(rContent).toEqual("Content");
|
||||
|
||||
const rBranch = await etapi.getEtapi(`branches/${branch.branchId}`);
|
||||
expect(rBranch.parentNoteId).toEqual("root");
|
||||
expect(rBranch.prefix).toEqual("Custom prefix");
|
||||
});
|
||||
|
||||
it("patch", async () => {
|
||||
const { note } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "text",
|
||||
title: "Hello World!",
|
||||
content: "Content"
|
||||
});
|
||||
|
||||
await etapi.patchEtapi(`notes/${note.noteId}`, {
|
||||
title: "new title",
|
||||
type: "code",
|
||||
mime: "text/apl",
|
||||
dateCreated: "2000-01-01 12:34:56.999+0200",
|
||||
utcDateCreated: "2000-01-01 10:34:56.999Z"
|
||||
});
|
||||
|
||||
const rNote = await etapi.getEtapi(`notes/${note.noteId}`);
|
||||
expect(rNote.title).toEqual("new title");
|
||||
expect(rNote.type).toEqual("code");
|
||||
expect(rNote.mime).toEqual("text/apl");
|
||||
expect(rNote.dateCreated).toEqual("2000-01-01 12:34:56.999+0200");
|
||||
expect(rNote.utcDateCreated).toEqual("2000-01-01 10:34:56.999Z");
|
||||
});
|
||||
|
||||
it("update content", async () => {
|
||||
const { note } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "text",
|
||||
title: "Hello World!",
|
||||
content: "Content"
|
||||
});
|
||||
|
||||
await etapi.putEtapiContent(`notes/${note.noteId}/content`, "new content");
|
||||
|
||||
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
|
||||
expect(rContent).toEqual("new content");
|
||||
});
|
||||
|
||||
it("create / update binary content", async () => {
|
||||
const { note } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "file",
|
||||
title: "Hello World!",
|
||||
content: "ZZZ"
|
||||
});
|
||||
|
||||
const updatedContent = crypto.randomBytes(16);
|
||||
|
||||
await etapi.putEtapiContent(`notes/${note.noteId}/content`, updatedContent);
|
||||
|
||||
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).arrayBuffer();
|
||||
expect(Buffer.from(new Uint8Array(rContent))).toEqual(updatedContent);
|
||||
});
|
||||
|
||||
it("delete note", async () => {
|
||||
const { note } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "text",
|
||||
title: "Hello World!",
|
||||
content: "Content"
|
||||
});
|
||||
|
||||
await etapi.deleteEtapi(`notes/${note.noteId}`);
|
||||
|
||||
const resp = await etapi.getEtapiResponse(`notes/${note.noteId}`);
|
||||
expect(resp.status).toEqual(404);
|
||||
|
||||
const error = await resp.json();
|
||||
expect(error.status).toEqual(404);
|
||||
expect(error.code).toEqual("NOTE_NOT_FOUND");
|
||||
expect(error.message).toEqual(`Note '${note.noteId}' not found.`);
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, beforeAll, afterAll } from "vitest";
|
||||
|
||||
let etapiAuthToken: string | undefined;
|
||||
|
||||
const getEtapiAuthorizationHeader = (): string => "Basic " + Buffer.from(`etapi:${etapiAuthToken}`).toString("base64");
|
||||
|
||||
const PORT: string = "9999";
|
||||
const HOST: string = "http://localhost:" + PORT;
|
||||
|
||||
type SpecDefinitionsFunc = () => void;
|
||||
|
||||
function describeEtapi(description: string, specDefinitions: SpecDefinitionsFunc): void {
|
||||
describe(description, () => {
|
||||
beforeAll(async () => {});
|
||||
|
||||
afterAll(() => {});
|
||||
|
||||
specDefinitions();
|
||||
});
|
||||
}
|
||||
|
||||
async function getEtapiResponse(url: string): Promise<Response> {
|
||||
return await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getEtapi(url: string): Promise<any> {
|
||||
const response = await getEtapiResponse(url);
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function getEtapiContent(url: string): Promise<Response> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
}
|
||||
});
|
||||
|
||||
checkStatus(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function postEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function postEtapiContent(url: string, data: BodyInit): Promise<Response> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: data
|
||||
});
|
||||
|
||||
checkStatus(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function putEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function putEtapiContent(url: string, data?: BodyInit): Promise<Response> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: data
|
||||
});
|
||||
|
||||
checkStatus(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function patchEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function deleteEtapi(url: string): Promise<any> {
|
||||
const response = await fetch(`${HOST}/etapi/${url}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: getEtapiAuthorizationHeader()
|
||||
}
|
||||
});
|
||||
return await processEtapiResponse(response);
|
||||
}
|
||||
|
||||
async function processEtapiResponse(response: Response): Promise<any> {
|
||||
const text = await response.text();
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`ETAPI error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return text?.trim() ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
function checkStatus(response: Response): void {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`ETAPI error ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
describeEtapi,
|
||||
getEtapi,
|
||||
getEtapiResponse,
|
||||
getEtapiContent,
|
||||
postEtapi,
|
||||
postEtapiContent,
|
||||
putEtapi,
|
||||
putEtapiContent,
|
||||
patchEtapi,
|
||||
deleteEtapi
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "./build",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["ES2023"],
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["./src/public/app/**/*"],
|
||||
"files": [
|
||||
"./src/public/app/types.d.ts",
|
||||
"./src/public/app/types-lib.d.ts",
|
||||
"./src/types.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -11,11 +11,11 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.12.0",
|
||||
"@redocly/cli": "2.12.3",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.2",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"typedoc": "0.28.15",
|
||||
"typedoc-plugin-missing-exports": "4.1.2"
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [
|
||||
...baseConfig
|
||||
];
|
||||
@@ -12,10 +12,10 @@
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest --coverage",
|
||||
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.19",
|
||||
"@fullcalendar/daygrid": "6.1.19",
|
||||
@@ -27,12 +27,14 @@
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.5.1",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@zumer/snapdom": "2.0.1",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
@@ -42,23 +44,23 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "16.5.0",
|
||||
"i18next": "25.6.3",
|
||||
"i18next": "25.7.1",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.25",
|
||||
"katex": "0.16.27",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.3.7",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.27.2",
|
||||
"react-i18next": "16.3.5",
|
||||
"preact": "10.28.0",
|
||||
"react-i18next": "16.4.0",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
@@ -72,7 +74,7 @@
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.1",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.0",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.0.11",
|
||||
|
||||
@@ -34,6 +34,7 @@ import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
|
||||
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
|
||||
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
|
||||
import type { InfoProps } from "../widgets/dialogs/info.jsx";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootContainer;
|
||||
@@ -124,7 +125,7 @@ export type CommandMappings = {
|
||||
isNewNote?: boolean;
|
||||
};
|
||||
showPromptDialog: PromptDialogOptions;
|
||||
showInfoDialog: ConfirmWithMessageOptions;
|
||||
showInfoDialog: InfoProps;
|
||||
showConfirmDialog: ConfirmWithMessageOptions;
|
||||
showRecentChanges: CommandData & { ancestorNoteId: string };
|
||||
showImportDialog: CommandData & { noteId: string };
|
||||
|
||||
@@ -22,6 +22,7 @@ bundleService.getWidgetBundlesByParent().then(async (widgetBundles) => {
|
||||
appContext.setLayout(new DesktopLayout(widgetBundles));
|
||||
appContext.start().catch((e) => {
|
||||
toastService.showPersistent({
|
||||
id: "critical-error",
|
||||
title: t("toast.critical-error.title"),
|
||||
icon: "alert",
|
||||
message: t("toast.critical-error.message", { message: e.message })
|
||||
|
||||
@@ -10,7 +10,6 @@ import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
|
||||
import HighlightsListWidget from "../widgets/highlights_list.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
@@ -30,7 +29,6 @@ import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import ScrollPadding from "../widgets/scroll_padding.js";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import SharedInfo from "../widgets/shared_info.jsx";
|
||||
import SpacerWidget from "../widgets/spacer.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import SqlResults from "../widgets/sql_result.js";
|
||||
import SqlTableSchemas from "../widgets/sql_table_schemas.js";
|
||||
@@ -43,8 +41,9 @@ import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import utils from "../services/utils.js";
|
||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import RightPanelWidget from "../widgets/sidebar/RightPanelWidget.jsx";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@@ -125,7 +124,7 @@ export default class DesktopLayout {
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(new SpacerWidget(0, 1))
|
||||
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
|
||||
.child(<MovePaneButton direction="left" />)
|
||||
.child(<MovePaneButton direction="right" />)
|
||||
.child(<ClosePaneButton />)
|
||||
@@ -185,14 +184,14 @@ export default class DesktopLayout {
|
||||
launcherPane = new FlexContainer("row")
|
||||
.css("height", "53px")
|
||||
.class("horizontal")
|
||||
.child(new LauncherContainer(true))
|
||||
.child(<LauncherContainer isHorizontalLayout={true} />)
|
||||
.child(<GlobalMenu isHorizontalLayout={true} />);
|
||||
} else {
|
||||
launcherPane = new FlexContainer("column")
|
||||
.css("width", "53px")
|
||||
.class("vertical")
|
||||
.child(<GlobalMenu isHorizontalLayout={false} />)
|
||||
.child(new LauncherContainer(false))
|
||||
.child(<LauncherContainer isHorizontalLayout={false} />)
|
||||
.child(<LeftPaneToggle isHorizontalLayout={false} />);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
|
||||
import ToastContainer from "../widgets/Toast.jsx";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
@@ -50,5 +51,6 @@ export function applyModals(rootContainer: RootContainer) {
|
||||
.child(<PromptDialog />)
|
||||
.child(<IncorrectCpuArchDialog />)
|
||||
.child(<PopupEditorDialog />)
|
||||
.child(<CallToActionDialog />);
|
||||
.child(<CallToActionDialog />)
|
||||
.child(<ToastContainer />)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
@@ -30,6 +29,7 @@ import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@@ -183,7 +183,7 @@ export default class MobileLayout {
|
||||
.child(new FlexContainer("row")
|
||||
.class("horizontal")
|
||||
.css("height", "53px")
|
||||
.child(new LauncherContainer(true))
|
||||
.child(<LauncherContainer isHorizontalLayout />)
|
||||
.child(<GlobalMenuWidget isHorizontalLayout />)
|
||||
.id("launcher-pane"))
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import utils from "./utils.js";
|
||||
import server from "./server.js";
|
||||
import toastService, { type ToastOptions } from "./toast.js";
|
||||
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
|
||||
import froca from "./froca.js";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import ws from "./ws.js";
|
||||
@@ -195,11 +195,11 @@ function filterRootNote(branchIds: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function makeToast(id: string, message: string): ToastOptions {
|
||||
function makeToast(id: string, message: string): ToastOptionsWithRequiredId {
|
||||
return {
|
||||
id: id,
|
||||
id,
|
||||
title: t("branches.delete-status"),
|
||||
message: message,
|
||||
message,
|
||||
icon: "trash"
|
||||
};
|
||||
}
|
||||
@@ -216,7 +216,7 @@ ws.subscribeToMessages(async (message) => {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("branches.delete-notes-in-progress", { count: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("branches.delete-finished-successfully"));
|
||||
toast.closeAfter = 5000;
|
||||
toast.timeout = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
}
|
||||
@@ -234,7 +234,7 @@ ws.subscribeToMessages(async (message) => {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("branches.undeleting-notes-in-progress", { count: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("branches.undeleting-notes-finished-successfully"));
|
||||
toast.closeAfter = 5000;
|
||||
toast.timeout = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
}
|
||||
@@ -242,7 +242,7 @@ ws.subscribeToMessages(async (message) => {
|
||||
|
||||
async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, prefix?: string) {
|
||||
const resp = await server.put<Response>(`notes/${childNoteId}/clone-to-branch/${parentBranchId}`, {
|
||||
prefix: prefix
|
||||
prefix
|
||||
});
|
||||
|
||||
if (!resp.success) {
|
||||
@@ -252,7 +252,7 @@ async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, pr
|
||||
|
||||
async function cloneNoteToParentNote(childNoteId: string, parentNoteId: string, prefix?: string) {
|
||||
const resp = await server.put<Response>(`notes/${childNoteId}/clone-to-note/${parentNoteId}`, {
|
||||
prefix: prefix
|
||||
prefix
|
||||
});
|
||||
|
||||
if (!resp.success) {
|
||||
|
||||
@@ -36,10 +36,17 @@ export async function executeBundle(bundle: Bundle, originEntity?: Entity | null
|
||||
}.call(apiContext);
|
||||
} catch (e: any) {
|
||||
const note = await froca.getNote(bundle.noteId);
|
||||
|
||||
const message = `Execution of JS note "${note?.title}" with ID ${bundle.noteId} failed with error: ${e?.message}`;
|
||||
showError(message);
|
||||
logError(message);
|
||||
toastService.showPersistent({
|
||||
id: `custom-script-failure-${note?.noteId}`,
|
||||
title: t("toast.bundle-error.title"),
|
||||
icon: "bx bx-error-circle",
|
||||
message: t("toast.bundle-error.message", {
|
||||
id: note?.noteId,
|
||||
title: note?.title,
|
||||
message: e.message
|
||||
})
|
||||
});
|
||||
logError("Widget initialization failed: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +109,9 @@ async function getWidgetBundlesByParent() {
|
||||
const noteId = bundle.noteId;
|
||||
const note = await froca.getNote(noteId);
|
||||
toastService.showPersistent({
|
||||
id: `custom-script-failure-${noteId}`,
|
||||
title: t("toast.bundle-error.title"),
|
||||
icon: "alert",
|
||||
icon: "bx bx-error-circle",
|
||||
message: t("toast.bundle-error.message", {
|
||||
id: noteId,
|
||||
title: note?.title,
|
||||
|
||||
@@ -32,6 +32,7 @@ function createClassForColor(colorString: string | null) {
|
||||
|
||||
$("head").append(`<style>
|
||||
.${className}, span.fancytree-active.${className} {
|
||||
--original-custom-color: ${color.hex()};
|
||||
--light-theme-custom-color: ${adjustedColor.lightThemeColor};
|
||||
--dark-theme-custom-color: ${adjustedColor.darkThemeColor};
|
||||
--custom-color-hue: ${hue ?? 'unset'};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Modal } from "bootstrap";
|
||||
import appContext from "../components/app_context.js";
|
||||
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js";
|
||||
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
|
||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
||||
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
||||
|
||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
|
||||
if (closeActDialog) {
|
||||
@@ -37,8 +38,8 @@ export function closeActiveDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
async function info(message: string) {
|
||||
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res }));
|
||||
async function info(message: MessageType, extraProps?: InfoExtraProps) {
|
||||
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { ...extraProps, message, callback: res }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,10 @@ export interface Api {
|
||||
|
||||
/**
|
||||
* Entity whose event triggered this execution.
|
||||
*
|
||||
* <p>
|
||||
* For front-end scripts, generally there's no origin entity specified since the scripts are run by the user or automatically by the UI (widgets).
|
||||
* If there is an origin entity specified, then it's going to be a note entity.
|
||||
*/
|
||||
originEntity: unknown | null;
|
||||
|
||||
@@ -278,12 +282,16 @@ export interface Api {
|
||||
getActiveContextNote(): FNote;
|
||||
|
||||
/**
|
||||
* @returns returns active context (split)
|
||||
* Obtains the currently active/focused split in the current tab.
|
||||
*
|
||||
* Note that this method does not return the note context of the "Quick edit" panel, it will return the note context behind it.
|
||||
*/
|
||||
getActiveContext(): NoteContext;
|
||||
|
||||
/**
|
||||
* @returns returns active main context (first split in a tab, represents the tab as a whole)
|
||||
* Obtains the main context of the current tab. This is the left-most split.
|
||||
*
|
||||
* Note that this method does not return the note context of the "Quick edit" panel, it will return the note context behind it.
|
||||
*/
|
||||
getActiveMainContext(): NoteContext;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import toastService, { type ToastOptions } from "./toast.js";
|
||||
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
|
||||
import server from "./server.js";
|
||||
import ws from "./ws.js";
|
||||
import utils from "./utils.js";
|
||||
@@ -57,11 +57,11 @@ export async function uploadFiles(entityType: string, parentNoteId: string, file
|
||||
}
|
||||
}
|
||||
|
||||
function makeToast(id: string, message: string): ToastOptions {
|
||||
function makeToast(id: string, message: string): ToastOptionsWithRequiredId {
|
||||
return {
|
||||
id: id,
|
||||
id,
|
||||
title: t("import.import-status"),
|
||||
message: message,
|
||||
message,
|
||||
icon: "plus"
|
||||
};
|
||||
}
|
||||
@@ -78,7 +78,7 @@ ws.subscribeToMessages(async (message) => {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("import.successful"));
|
||||
toast.closeAfter = 5000;
|
||||
toast.timeout = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
|
||||
@@ -100,7 +100,7 @@ ws.subscribeToMessages(async (message: WebSocketMessage) => {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("import.successful"));
|
||||
toast.closeAfter = 5000;
|
||||
toast.timeout = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import server from "./server.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
import toastService from "./toast.js";
|
||||
import type { ToastOptions } from "./toast.js";
|
||||
import type { ToastOptionsWithRequiredId } from "./toast.js";
|
||||
import ws from "./ws.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import froca from "./froca.js";
|
||||
@@ -97,7 +97,7 @@ async function protectNote(noteId: string, protect: boolean, includingSubtree: b
|
||||
await server.put(`notes/${noteId}/protect/${protect ? 1 : 0}?subtree=${includingSubtree ? 1 : 0}`);
|
||||
}
|
||||
|
||||
function makeToast(message: Message, title: string, text: string): ToastOptions {
|
||||
function makeToast(message: Message, title: string, text: string): ToastOptionsWithRequiredId {
|
||||
return {
|
||||
id: message.taskId,
|
||||
title,
|
||||
@@ -124,7 +124,7 @@ ws.subscribeToMessages(async (message) => {
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const text = isProtecting ? t("protected_session.protecting-finished-successfully") : t("protected_session.unprotecting-finished-successfully");
|
||||
const toast = makeToast(message, title, text);
|
||||
toast.closeAfter = 3000;
|
||||
toast.timeout = 3000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ async function reportError(method: string, url: string, statusCode: number, resp
|
||||
|
||||
const toastService = (await import("./toast.js")).default;
|
||||
|
||||
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
|
||||
const messageStr = (typeof message === "string" ? message : JSON.stringify(message)) || "-";
|
||||
|
||||
if ([400, 404].includes(statusCode) && response && typeof response === "object") {
|
||||
toastService.showError(messageStr);
|
||||
@@ -274,10 +274,22 @@ async function reportError(method: string, url: string, statusCode: number, resp
|
||||
...response
|
||||
});
|
||||
} else {
|
||||
const title = `${statusCode} ${method} ${url}`;
|
||||
toastService.showErrorTitleAndMessage(title, messageStr);
|
||||
const { t } = await import("./i18n.js");
|
||||
if (statusCode === 400 && (url.includes("%23") || url.includes("%2F"))) {
|
||||
toastService.showPersistent({
|
||||
id: "trafik-blocked",
|
||||
icon: "bx bx-unlink",
|
||||
title: t("server.unknown_http_error_title"),
|
||||
message: t("server.traefik_blocks_requests")
|
||||
});
|
||||
} else {
|
||||
toastService.showErrorTitleAndMessage(
|
||||
t("server.unknown_http_error_title"),
|
||||
t("server.unknown_http_error_content", { statusCode, method, url, message: messageStr }),
|
||||
15_000);
|
||||
}
|
||||
const { throwError } = await import("./ws.js");
|
||||
throwError(`${title} - ${message}`);
|
||||
throwError(`${statusCode} ${method} ${url} - ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import utils from "./utils.js";
|
||||
|
||||
type ElementType = HTMLElement | Document;
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
export type Handler = (e: KeyboardEvent) => void;
|
||||
|
||||
export interface ShortcutBinding {
|
||||
element: HTMLElement | Document;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { signal } from "@preact/signals";
|
||||
|
||||
import utils from "./utils.js";
|
||||
|
||||
export interface ToastOptions {
|
||||
@@ -5,117 +7,84 @@ export interface ToastOptions {
|
||||
icon: string;
|
||||
title?: string;
|
||||
message: string;
|
||||
delay?: number;
|
||||
autohide?: boolean;
|
||||
closeAfter?: number;
|
||||
timeout?: number;
|
||||
progress?: number;
|
||||
buttons?: {
|
||||
text: string;
|
||||
onClick: (api: { dismissToast: () => void }) => void;
|
||||
}[];
|
||||
}
|
||||
|
||||
function toast({ title, icon, message, id, delay, autohide, progress }: ToastOptions) {
|
||||
const $toast = $(title
|
||||
? `\
|
||||
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">
|
||||
<span class="bx bx-${icon}"></span>
|
||||
<span class="toast-title"></span>
|
||||
</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
<div class="toast-progress"></div>
|
||||
</div>`
|
||||
: `
|
||||
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-icon">
|
||||
<span class="bx bx-${icon}"></span>
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
<div class="toast-header">
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-progress"></div>
|
||||
</div>`
|
||||
);
|
||||
export type ToastOptionsWithRequiredId = Omit<ToastOptions, "id"> & Required<Pick<ToastOptions, "id">>;
|
||||
|
||||
$toast.toggleClass("no-title", !title);
|
||||
$toast.find(".toast-title").text(title ?? "");
|
||||
$toast.find(".toast-body").html(message);
|
||||
$toast.find(".toast-progress").css("width", `${(progress ?? 0) * 100}%`);
|
||||
|
||||
if (id) {
|
||||
$toast.attr("id", `toast-${id}`);
|
||||
}
|
||||
|
||||
$("#toast-container").append($toast);
|
||||
|
||||
$toast.toast({
|
||||
delay: delay || 3000,
|
||||
autohide: !!autohide
|
||||
});
|
||||
|
||||
$toast.on("hidden.bs.toast", (e) => e.target.remove());
|
||||
|
||||
$toast.toast("show");
|
||||
|
||||
return $toast;
|
||||
}
|
||||
|
||||
function showPersistent(options: ToastOptions) {
|
||||
let $toast = $(`#toast-${options.id}`);
|
||||
|
||||
if ($toast.length > 0) {
|
||||
$toast.find(".toast-body").html(options.message);
|
||||
$toast.find(".toast-progress").css("width", `${(options.progress ?? 0) * 100}%`);
|
||||
function showPersistent(options: ToastOptionsWithRequiredId) {
|
||||
const existingToast = toasts.value.find(toast => toast.id === options.id);
|
||||
if (existingToast) {
|
||||
updateToast(options.id, options);
|
||||
} else {
|
||||
options.autohide = false;
|
||||
|
||||
$toast = toast(options);
|
||||
}
|
||||
|
||||
if (options.closeAfter) {
|
||||
setTimeout(() => $toast.remove(), options.closeAfter);
|
||||
addToast(options);
|
||||
}
|
||||
}
|
||||
|
||||
function closePersistent(id: string) {
|
||||
$(`#toast-${id}`).remove();
|
||||
removeToastFromStore(id);
|
||||
}
|
||||
|
||||
function showMessage(message: string, delay = 2000, icon = "check") {
|
||||
function showMessage(message: string, timeout = 2000, icon = "bx bx-check") {
|
||||
console.debug(utils.now(), "message:", message);
|
||||
|
||||
toast({
|
||||
addToast({
|
||||
icon,
|
||||
message: message,
|
||||
autohide: true,
|
||||
delay
|
||||
message,
|
||||
timeout
|
||||
});
|
||||
}
|
||||
|
||||
export function showError(message: string, delay = 10000) {
|
||||
export function showError(message: string, timeout = 10000) {
|
||||
console.log(utils.now(), "error: ", message);
|
||||
|
||||
toast({
|
||||
icon: "alert",
|
||||
message: message,
|
||||
autohide: true,
|
||||
delay
|
||||
addToast({
|
||||
icon: "bx bx-error-circle",
|
||||
message,
|
||||
timeout
|
||||
});
|
||||
}
|
||||
|
||||
function showErrorTitleAndMessage(title: string, message: string, delay = 10000) {
|
||||
function showErrorTitleAndMessage(title: string, message: string, timeout = 10000) {
|
||||
console.log(utils.now(), "error: ", message);
|
||||
|
||||
toast({
|
||||
title: title,
|
||||
icon: "alert",
|
||||
message: message,
|
||||
autohide: true,
|
||||
delay
|
||||
addToast({
|
||||
title,
|
||||
icon: "bx bx-error-circle",
|
||||
message,
|
||||
timeout
|
||||
});
|
||||
}
|
||||
|
||||
//#region Toast store
|
||||
export const toasts = signal<ToastOptionsWithRequiredId[]>([]);
|
||||
|
||||
function addToast(opts: ToastOptions) {
|
||||
const id = opts.id ?? crypto.randomUUID();
|
||||
const toast = { ...opts, id };
|
||||
toasts.value = [ ...toasts.value, toast ];
|
||||
return id;
|
||||
}
|
||||
|
||||
function updateToast(id: string, partial: Partial<ToastOptions>) {
|
||||
toasts.value = toasts.value.map(toast => {
|
||||
if (toast.id === id) {
|
||||
return { ...toast, ...partial }
|
||||
}
|
||||
return toast;
|
||||
});
|
||||
}
|
||||
|
||||
export function removeToastFromStore(id: string) {
|
||||
toasts.value = toasts.value.filter(toast => toast.id !== id);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
export default {
|
||||
showMessage,
|
||||
showError,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { dayjs } from "@triliumnext/commons";
|
||||
import type { ViewScope } from "./link.js";
|
||||
import FNote from "../entities/fnote";
|
||||
import { snapdom } from "@zumer/snapdom";
|
||||
|
||||
const SVG_MIME = "image/svg+xml";
|
||||
|
||||
@@ -150,7 +151,7 @@ export function isMac() {
|
||||
|
||||
export const hasTouchBar = (isMac() && isElectron());
|
||||
|
||||
function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement> | JQueryEventObject) {
|
||||
export function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement> | JQueryEventObject) {
|
||||
return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey);
|
||||
}
|
||||
|
||||
@@ -236,7 +237,7 @@ export function isIOS() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function isDesktop() {
|
||||
export function isDesktop() {
|
||||
return (
|
||||
window.glob?.device === "desktop" ||
|
||||
// window.glob.device is not available in setup
|
||||
@@ -628,16 +629,69 @@ export function createImageSrcUrl(note: FNote) {
|
||||
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Given a string representation of an SVG, triggers a download of the file on the client device.
|
||||
* Helper function to prepare an element for snapdom rendering.
|
||||
* Handles string parsing and temporary DOM attachment for style computation.
|
||||
*
|
||||
* @param source - Either an SVG/HTML string to be parsed, or an existing SVG/HTML element.
|
||||
* @returns An object containing the prepared element and a cleanup function.
|
||||
* The cleanup function removes temporarily attached elements from the DOM,
|
||||
* or is a no-op if the element was already in the DOM.
|
||||
*/
|
||||
function prepareElementForSnapdom(source: string | SVGElement | HTMLElement): {
|
||||
element: SVGElement | HTMLElement;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
if (typeof source === 'string') {
|
||||
const parser = new DOMParser();
|
||||
|
||||
// Detect if content is SVG or HTML
|
||||
const isSvg = source.trim().startsWith('<svg');
|
||||
const mimeType = isSvg ? SVG_MIME : 'text/html';
|
||||
|
||||
const doc = parser.parseFromString(source, mimeType);
|
||||
const element = doc.documentElement;
|
||||
|
||||
// Temporarily attach to DOM for proper style computation
|
||||
element.style.position = 'absolute';
|
||||
element.style.left = '-9999px';
|
||||
element.style.top = '-9999px';
|
||||
document.body.appendChild(element);
|
||||
|
||||
return {
|
||||
element,
|
||||
cleanup: () => document.body.removeChild(element)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
element: source,
|
||||
cleanup: () => {} // No-op for existing elements
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an SVG using snapdom for proper rendering. Can accept either an SVG string, an SVG element, or an HTML element.
|
||||
*
|
||||
* @param nameWithoutExtension the name of the file. The .svg suffix is automatically added to it.
|
||||
* @param svgContent the content of the SVG file download.
|
||||
* @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be downloaded.
|
||||
*/
|
||||
function downloadSvg(nameWithoutExtension: string, svgContent: string) {
|
||||
const filename = `${nameWithoutExtension}.svg`;
|
||||
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
|
||||
triggerDownload(filename, dataUrl);
|
||||
async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
|
||||
const { element, cleanup } = prepareElementForSnapdom(svgSource);
|
||||
|
||||
try {
|
||||
const result = await snapdom(element, {
|
||||
backgroundColor: "transparent",
|
||||
scale: 2
|
||||
});
|
||||
triggerDownload(`${nameWithoutExtension}.svg`, result.url);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -658,62 +712,26 @@ function triggerDownload(fileName: string, dataUrl: string) {
|
||||
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a string representation of an SVG, renders the SVG to PNG and triggers a download of the file on the client device.
|
||||
*
|
||||
* Note that the SVG must specify its width and height as attributes in order for it to be rendered.
|
||||
* Downloads an SVG as PNG using snapdom. Can accept either an SVG string, an SVG element, or an HTML element.
|
||||
*
|
||||
* @param nameWithoutExtension the name of the file. The .png suffix is automatically added to it.
|
||||
* @param svgContent the content of the SVG file download.
|
||||
* @returns a promise which resolves if the operation was successful, or rejects if it failed (permissions issue or some other issue).
|
||||
* @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be converted to PNG.
|
||||
*/
|
||||
function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// First, we need to determine the width and the height from the input SVG.
|
||||
const result = getSizeFromSvg(svgContent);
|
||||
if (!result) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
async function downloadAsPng(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
|
||||
const { element, cleanup } = prepareElementForSnapdom(svgSource);
|
||||
|
||||
// Convert the image to a blob.
|
||||
const { width, height } = result;
|
||||
|
||||
// Create an image element and load the SVG.
|
||||
const imageEl = new Image();
|
||||
imageEl.width = width;
|
||||
imageEl.height = height;
|
||||
imageEl.crossOrigin = "anonymous";
|
||||
imageEl.onload = () => {
|
||||
try {
|
||||
// Draw the image with a canvas.
|
||||
const canvasEl = document.createElement("canvas");
|
||||
canvasEl.width = imageEl.width;
|
||||
canvasEl.height = imageEl.height;
|
||||
document.body.appendChild(canvasEl);
|
||||
|
||||
const ctx = canvasEl.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject();
|
||||
}
|
||||
|
||||
ctx?.drawImage(imageEl, 0, 0);
|
||||
|
||||
const imgUri = canvasEl.toDataURL("image/png")
|
||||
triggerDownload(`${nameWithoutExtension}.png`, imgUri);
|
||||
document.body.removeChild(canvasEl);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
reject();
|
||||
}
|
||||
};
|
||||
imageEl.onerror = (e) => reject(e);
|
||||
imageEl.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
|
||||
});
|
||||
try {
|
||||
const result = await snapdom(element, {
|
||||
backgroundColor: "transparent",
|
||||
scale: 2
|
||||
});
|
||||
const pngImg = await result.toPng();
|
||||
await triggerDownload(`${nameWithoutExtension}.png`, pngImg.src);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export function getSizeFromSvg(svgContent: string) {
|
||||
const svgDocument = (new DOMParser()).parseFromString(svgContent, SVG_MIME);
|
||||
|
||||
@@ -925,8 +943,8 @@ export default {
|
||||
areObjectsEqual,
|
||||
copyHtmlToClipboard,
|
||||
createImageSrcUrl,
|
||||
downloadSvg,
|
||||
downloadSvgAsPng,
|
||||
downloadAsSvg,
|
||||
downloadAsPng,
|
||||
compareVersions,
|
||||
isUpdateAvailable,
|
||||
isLaunchBarConfig
|
||||
|
||||
@@ -1015,15 +1015,9 @@ div[data-notify="container"] {
|
||||
font-family: var(--monospace-font-family);
|
||||
}
|
||||
|
||||
svg.ck-icon {
|
||||
&.ck-icon_inherit-color path[fill="#333"] {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
&.note-icon {
|
||||
color: var(--main-text-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
svg.ck-icon.note-icon {
|
||||
color: var(--main-text-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ck-content {
|
||||
@@ -1141,61 +1135,6 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
--bs-toast-bg: var(--accented-background-color);
|
||||
--bs-toast-color: var(--main-text-color);
|
||||
z-index: 9999999999 !important;
|
||||
pointer-events: all;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-header {
|
||||
background-color: var(--more-accented-background-color) !important;
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
white-space: preserve-breaks;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast.no-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.toast.no-title .toast-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);
|
||||
}
|
||||
|
||||
.toast.no-title .toast-body {
|
||||
padding-inline-start: 0;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.toast.no-title .toast-header {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.toast .toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
background-color: var(--toast-text-color) !important;
|
||||
height: 4px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.ck-mentions .ck-button {
|
||||
font-size: var(--detail-font-size) !important;
|
||||
padding: 5px;
|
||||
|
||||
@@ -114,4 +114,8 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
|
||||
|
||||
.use-note-color {
|
||||
--custom-color: var(--dark-theme-custom-color);
|
||||
}
|
||||
}
|
||||
|
||||
span.fancytree-active {
|
||||
color: var(--dark-theme-custom-color);
|
||||
}
|
||||
|
||||
@@ -98,4 +98,8 @@ html {
|
||||
|
||||
.use-note-color {
|
||||
--custom-color: var(--light-theme-custom-color);
|
||||
}
|
||||
}
|
||||
|
||||
span.fancytree-active {
|
||||
color: var(--light-theme-custom-color);
|
||||
}
|
||||
|
||||
@@ -526,11 +526,14 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel).ck
|
||||
.ck-mermaid__editing-view {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--code-block-box-shadow);
|
||||
padding: 0;
|
||||
box-shadow: var(--code-block-box-shadow);
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
|
||||
:root .ck-content pre:has(> code) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root .ck-content pre {
|
||||
--icon-button-size: 1.8em;
|
||||
--copy-button-width: var(--icon-button-size);
|
||||
|
||||
@@ -212,7 +212,8 @@ body[dir=ltr] #launcher-container {
|
||||
}
|
||||
|
||||
#launcher-pane .launcher-button,
|
||||
#launcher-pane .dropdown {
|
||||
#launcher-pane .right-dropdown-widget,
|
||||
#launcher-pane .global-menu {
|
||||
width: calc(var(--launcher-pane-size) - (var(--launcher-pane-button-margin) * 2)) !important;
|
||||
height: calc(var(--launcher-pane-size) - (var(--launcher-pane-button-margin) * 2)) !important;
|
||||
margin: var(--launcher-pane-button-gap) var(--launcher-pane-button-margin);
|
||||
|
||||
@@ -948,7 +948,7 @@
|
||||
"move-to-available-launchers": "نقل الى المشغلات المتوفرة",
|
||||
"duplicate-launcher": "تكرار المشغل <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"editable_text": {
|
||||
"auto-detect-language": "تم اكتشافه تلقائيا"
|
||||
},
|
||||
"classic_editor_toolbar": {
|
||||
|
||||
@@ -205,7 +205,8 @@
|
||||
"info": {
|
||||
"modalTitle": "信息消息",
|
||||
"closeButton": "关闭",
|
||||
"okButton": "确定"
|
||||
"okButton": "确定",
|
||||
"copy_to_clipboard": "复制到剪切板"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_button": "全文搜索",
|
||||
@@ -986,7 +987,14 @@
|
||||
"placeholder": "在这里输入您的代码笔记内容..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "在这里输入您的笔记内容..."
|
||||
"placeholder": "在这里输入您的笔记内容...",
|
||||
"auto-detect-language": "自动检测",
|
||||
"keeps-crashing": "编辑组件时持续崩溃。请尝试重启 Trilium。如果问题仍然存在,请考虑提交错误报告。",
|
||||
"editor_crashed_title": "文本编辑器崩溃",
|
||||
"editor_crashed_content": "您的内容已经成功恢复,但是某些最近的内容可能没有保存。",
|
||||
"editor_crashed_details_button": "浏览更多明细...",
|
||||
"editor_crashed_details_intro": "如果您多次经历此错误,考虑在Github上提交这些信息。",
|
||||
"editor_crashed_details_title": "技术信息"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "通过在下面的输入框中输入笔记标题或在树中选择笔记来打开笔记。",
|
||||
@@ -1469,7 +1477,7 @@
|
||||
"import-into-note": "导入到笔记",
|
||||
"apply-bulk-actions": "应用批量操作",
|
||||
"converted-to-attachments": "{{count}} 个笔记已被转换为附件。",
|
||||
"convert-to-attachment-confirm": "确定要将选中的笔记转换为其父笔记的附件吗?",
|
||||
"convert-to-attachment-confirm": "确定要将选中的笔记转换为其父笔记的附件吗?此操作仅适用于图像笔记,其他笔记将被跳过。",
|
||||
"duplicate": "复制",
|
||||
"open-in-popup": "快速编辑",
|
||||
"archive": "归档",
|
||||
@@ -1660,10 +1668,6 @@
|
||||
"move-to-available-launchers": "移动到可用启动器",
|
||||
"duplicate-launcher": "复制启动器 <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "自动检测",
|
||||
"keeps-crashing": "编辑组件时持续崩溃。请尝试重启 Trilium。如果问题仍然存在,请考虑提交错误报告。"
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "代码块",
|
||||
"description": "控制文本笔记中代码块的语法高亮,代码笔记不会受到影响。",
|
||||
@@ -1714,7 +1718,8 @@
|
||||
"open_note_in_new_tab": "在新标签页中打开笔记",
|
||||
"open_note_in_new_split": "在新分屏中打开笔记",
|
||||
"open_note_in_new_window": "在新窗口中打开笔记",
|
||||
"open_note_in_popup": "快速编辑"
|
||||
"open_note_in_popup": "快速编辑",
|
||||
"open_note_in_other_split": "在另一个分屏中打开笔记"
|
||||
},
|
||||
"electron_integration": {
|
||||
"desktop-application": "桌面应用程序",
|
||||
@@ -1953,7 +1958,8 @@
|
||||
"button_title": "将图表导出为PNG"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "无法将图表导出为PNG。"
|
||||
"export_to_png": "无法将图表导出为PNG。",
|
||||
"export_to_svg": "此图像无法导出为SVG。"
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "外观",
|
||||
@@ -2105,5 +2111,10 @@
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "切换至完整编辑器"
|
||||
},
|
||||
"server": {
|
||||
"unknown_http_error_title": "与服务器通讯错误",
|
||||
"unknown_http_error_content": "状态码: {{statusCode}}\n地址: {{method}} {{url}}\n信息: {{message}}",
|
||||
"traefik_blocks_requests": "如果您使用 Traefik 反向代理,它引入了一项影响与服务器的通信重大更改。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,8 @@
|
||||
"quickSearch": "Fokus auf schnelle Sucheingabe",
|
||||
"inPageSearch": "Auf-der-Seite-Suche",
|
||||
"newTabWithActivationNoteLink": "auf einen Notiz-Link öffnet und aktiviert die Notiz in einem neuen Tab",
|
||||
"title": "Spickzettel"
|
||||
"title": "Spickzettel",
|
||||
"editShortcuts": "Tastenkürzel bearbeiten"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "In Notiz importieren",
|
||||
@@ -983,7 +984,9 @@
|
||||
"placeholder": "Gebe hier den Inhalt deiner Codenotiz ein..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Gebe hier den Inhalt deiner Notiz ein..."
|
||||
"placeholder": "Gebe hier den Inhalt deiner Notiz ein...",
|
||||
"auto-detect-language": "Automatisch erkannt",
|
||||
"keeps-crashing": "Die Bearbeitungskomponente stürzt immer wieder ab. Bitte starten Sie Trilium neu. Wenn das Problem weiterhin besteht, erstellen Sie einen Fehlerbericht."
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Öffne eine Notiz, indem du den Titel der Notiz in die Eingabe unten eingibst oder eine Notiz in der Baumstruktur auswählst.",
|
||||
@@ -1624,10 +1627,6 @@
|
||||
"move-to-available-launchers": "Zu verfügbaren Launchern verschieben",
|
||||
"duplicate-launcher": "Launcher duplizieren <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Automatisch erkannt",
|
||||
"keeps-crashing": "Die Bearbeitungskomponente stürzt immer wieder ab. Bitte starten Sie Trilium neu. Wenn das Problem weiterhin besteht, erstellen Sie einen Fehlerbericht."
|
||||
},
|
||||
"highlighting": {
|
||||
"description": "Steuert die Syntaxhervorhebung für Codeblöcke in Textnotizen, Code-Notizen sind nicht betroffen.",
|
||||
"color-scheme": "Farbschema",
|
||||
@@ -2102,5 +2101,8 @@
|
||||
"clear-color": "Notizfarbe entfernen",
|
||||
"set-color": "Notizfarbe wählen",
|
||||
"set-custom-color": "Eigene Notizfarbe wählen"
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "Wechsele zum vollständigen Editor"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,8 @@
|
||||
"info": {
|
||||
"modalTitle": "Info message",
|
||||
"closeButton": "Close",
|
||||
"okButton": "OK"
|
||||
"okButton": "OK",
|
||||
"copy_to_clipboard": "Copy to clipboard"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Search for note by its name or type > for commands...",
|
||||
@@ -987,7 +988,14 @@
|
||||
"placeholder": "Type the content of your code note here..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Type the content of your note here..."
|
||||
"placeholder": "Type the content of your note here...",
|
||||
"editor_crashed_title": "The text editor crashed",
|
||||
"editor_crashed_content": "Your content was recovered successfully, but a few of your most recent changes may not have been saved.",
|
||||
"editor_crashed_details_button": "View more details...",
|
||||
"editor_crashed_details_intro": "If you experience this error several times, consider reporting it on GitHub by pasting the information below.",
|
||||
"editor_crashed_details_title": "Technical information",
|
||||
"auto-detect-language": "Auto-detected",
|
||||
"keeps-crashing": "Editing component keeps crashing. Please try restarting Trilium. If problem persists, consider creating a bug report."
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Open a note by typing the note's title into the input below or choose a note in the tree.",
|
||||
@@ -1826,10 +1834,6 @@
|
||||
"move-to-available-launchers": "Move to available launchers",
|
||||
"duplicate-launcher": "Duplicate launcher <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Auto-detected",
|
||||
"keeps-crashing": "Editing component keeps crashing. Please try restarting Trilium. If problem persists, consider creating a bug report."
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "Code Blocks",
|
||||
"description": "Controls the syntax highlighting for code blocks inside text notes, code notes will not be affected.",
|
||||
@@ -1968,7 +1972,8 @@
|
||||
"button_title": "Export diagram as PNG"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "The diagram could not be exported to PNG."
|
||||
"export_to_png": "The diagram could not be exported to PNG.",
|
||||
"export_to_svg": "The diagram could not be exported to SVG."
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "Appearance",
|
||||
@@ -2107,5 +2112,10 @@
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "Switch to full editor"
|
||||
},
|
||||
"server": {
|
||||
"unknown_http_error_title": "Communication error with the server",
|
||||
"unknown_http_error_content": "Status code: {{statusCode}}\nURL: {{method}} {{url}}\nMessage: {{message}}",
|
||||
"traefik_blocks_requests": "If you are using the Traefik reverse proxy, it introduced a breaking change which affects the communication with the server."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,7 +987,8 @@
|
||||
"placeholder": "Escriba el contenido de su nota de código aquí..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Escribe aquí el contenido de tu nota..."
|
||||
"placeholder": "Escribe aquí el contenido de tu nota...",
|
||||
"auto-detect-language": "Detectado automáticamente"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Abra una nota escribiendo el título de la nota en la entrada a continuación o elija una nota en el árbol.",
|
||||
@@ -1812,9 +1813,6 @@
|
||||
"move-to-available-launchers": "Mover a lanzadores disponibles",
|
||||
"duplicate-launcher": "Duplicar lanzador <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Detectado automáticamente"
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "Bloques de código",
|
||||
"description": "Controla el resaltado de sintaxis para bloques de código dentro de las notas de texto, las notas de código no serán afectadas.",
|
||||
|
||||
@@ -981,7 +981,8 @@
|
||||
"placeholder": "Saisir le contenu de votre note de code ici..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Saisir le contenu de votre note ici..."
|
||||
"placeholder": "Saisir le contenu de votre note ici...",
|
||||
"auto-detect-language": "Détecté automatiquement"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Ouvrez une note en tapant son titre dans la zone ci-dessous ou choisissez une note dans l'arborescence.",
|
||||
@@ -1617,9 +1618,6 @@
|
||||
"move-to-available-launchers": "Déplacer vers les raccourcis disponibles",
|
||||
"duplicate-launcher": "Dupliquer le raccourci <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Détecté automatiquement"
|
||||
},
|
||||
"highlighting": {
|
||||
"description": "Contrôle la coloration syntaxique des blocs de code à l'intérieur des notes texte, les notes de code ne seront pas affectées.",
|
||||
"color-scheme": "Jeu de couleurs",
|
||||
|
||||
@@ -1491,7 +1491,9 @@
|
||||
"placeholder": "Digita qui il contenuto della tua nota di codice..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Digita qui il contenuto della tua nota..."
|
||||
"placeholder": "Digita qui il contenuto della tua nota...",
|
||||
"auto-detect-language": "Rilevato automaticamente",
|
||||
"keeps-crashing": "Il componente di modifica continua a bloccarsi. Prova a riavviare Trilium. Se il problema persiste, valuta la possibilità di creare una segnalazione di bug."
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Apri una nota digitandone il titolo nel campo sottostante oppure scegli una nota nell'albero.",
|
||||
@@ -1937,10 +1939,6 @@
|
||||
"move-to-available-launchers": "Passa ai launcher disponibili",
|
||||
"duplicate-launcher": "Duplica il launcher <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Rilevato automaticamente",
|
||||
"keeps-crashing": "Il componente di modifica continua a bloccarsi. Prova a riavviare Trilium. Se il problema persiste, valuta la possibilità di creare una segnalazione di bug."
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "Blocchi di codice",
|
||||
"description": "Controlla l'evidenziazione della sintassi per i blocchi di codice all'interno delle note di testo; le note di codice non saranno interessate.",
|
||||
|
||||
@@ -290,7 +290,7 @@
|
||||
"pasteNotes": "ノートをサブノートとしてアクティブノートに貼り付ける(コピーされたか切り取りされたかに よって、移動またはクローンになる)",
|
||||
"deleteNotes": "ノート/サブツリーを削除",
|
||||
"editingNotes": "ノート編集",
|
||||
"editNoteTitle": "ツリーペインでEnterキーを押すと、ツリーペインからノートタイトルに切り替わります。ノートタイトルだとテキストエディタにフォーカスが切り替わります。<kbd>Ctrl+.</kbd> を押すと、エディタからツリーペインに戻ります。",
|
||||
"editNoteTitle": "ツリーペインでEnterキーを押すと、ツリーペインからノートタイトルに切り替わります。ノートタイトルだとテキストエディターにフォーカスが切り替わります。<kbd>Ctrl+.</kbd> を押すと、エディタからツリーペインに戻ります。",
|
||||
"createEditLink": "外部リンクの作成/編集",
|
||||
"createInternalLink": "内部リンクの作成",
|
||||
"followLink": "カーソル下のリンクをたどる",
|
||||
@@ -421,7 +421,7 @@
|
||||
"apply-bulk-actions": "一括操作の適用",
|
||||
"converted-to-attachments": "{{count}}ノートが添付ファイルに変換されました。",
|
||||
"convert-to-attachment": "添付ファイルに変換",
|
||||
"convert-to-attachment-confirm": "選択したノートを親ノートの添付ファイルに変換しますか?",
|
||||
"convert-to-attachment-confirm": "選択したノートを親ノートの添付ファイルに変換してもよろしいですか?この操作は画像ノートにのみ適用され、その他のノートはスキップされます。",
|
||||
"open-in-popup": "クイック編集",
|
||||
"hoist-note": "ホイストノート",
|
||||
"unhoist-note": "ノートをホイストしない",
|
||||
@@ -526,7 +526,7 @@
|
||||
"button_title": "ボタンを表示"
|
||||
},
|
||||
"svg_export_button": {
|
||||
"button_title": "図をSVGとしてエクスポート"
|
||||
"button_title": "図をSVG形式でエクスポート"
|
||||
},
|
||||
"book_properties": {
|
||||
"grid": "グリッド",
|
||||
@@ -1011,7 +1011,8 @@
|
||||
"info": {
|
||||
"closeButton": "閉じる",
|
||||
"modalTitle": "情報メッセージ",
|
||||
"okButton": "OK"
|
||||
"okButton": "OK",
|
||||
"copy_to_clipboard": "クリップボードにコピー"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "閉じる",
|
||||
@@ -1268,10 +1269,6 @@
|
||||
"duplicate-launcher": "ランチャーの複製 <kbd data-command=\"duplicateSubtree\">",
|
||||
"reset_launcher_confirm": "本当に「{{title}}」をリセットしますか? このノート(およびその子ノート)のすべてのデータと設定が失われ、ランチャーは元の場所に戻ります。"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "自動検出",
|
||||
"keeps-crashing": "編集コンポーネントがクラッシュし続けます。Trilium を再起動してください。問題が解決しない場合は、バグレポートの作成をご検討ください。"
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "コードブロック",
|
||||
"description": "テキストノート内のコードブロックのシンタックスハイライトを制御します。コードノートには影響しません。",
|
||||
@@ -1344,7 +1341,8 @@
|
||||
"button_title": "図をPNG形式でエクスポート"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "図をPNG形式でエクスポートできませんでした。"
|
||||
"export_to_png": "図をPNG形式でエクスポートできませんでした。",
|
||||
"export_to_svg": "図をSVG形式でエクスポートできませんでした。"
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "外観",
|
||||
@@ -1778,7 +1776,14 @@
|
||||
"placeholder": "ここにコードノートの内容を入力..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "ここにノートの内容を入力..."
|
||||
"placeholder": "ここにノートの内容を入力...",
|
||||
"auto-detect-language": "自動検出",
|
||||
"keeps-crashing": "編集コンポーネントがクラッシュし続けます。Trilium を再起動してください。問題が解決しない場合は、バグレポートの作成をご検討ください。",
|
||||
"editor_crashed_title": "テキストエディターがクラッシュしました",
|
||||
"editor_crashed_content": "コンテンツは正常に復元されましたが、最近の変更の一部が保存されていない可能性があります。",
|
||||
"editor_crashed_details_button": "詳細を見る...",
|
||||
"editor_crashed_details_intro": "このエラーが何度も発生する場合は、以下の情報を貼り付けて GitHub に報告することを検討してください。",
|
||||
"editor_crashed_details_title": "技術情報"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "以下の入力欄にノートのタイトルを入力するか、ツリー内のノートを選択してノートを開きます。",
|
||||
@@ -2106,5 +2111,10 @@
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "フルエディターに切り替え"
|
||||
},
|
||||
"server": {
|
||||
"unknown_http_error_title": "サーバーとの通信エラー",
|
||||
"unknown_http_error_content": "ステータスコード: {{statusCode}}\nURL: {{method}} {{url}}\nメッセージ: {{message}}",
|
||||
"traefik_blocks_requests": "Traefik リバース プロキシを使用している場合、サーバーとの通信に影響する重大な変更が導入されました。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,24 @@
|
||||
"available_actions": "가능한 액션들",
|
||||
"chosen_actions": "선택한 액션들",
|
||||
"execute_bulk_actions": "대량 액션들 실행",
|
||||
"bulk_actions_executed": "대량 액션들이 성공적으로 실행되었습니다."
|
||||
"bulk_actions_executed": "대량 액션들이 성공적으로 실행되었습니다.",
|
||||
"none_yet": "아직 없습니다... 위에 있는 가능한 작업 중 하나를 클릭하여 작업을 추가하세요.",
|
||||
"labels": "라벨",
|
||||
"relations": "관계",
|
||||
"notes": "노트",
|
||||
"other": "기타"
|
||||
},
|
||||
"i18n": {
|
||||
"saturday": "토요일",
|
||||
"sunday": "일요일",
|
||||
"first-week-of-the-year": "일년의 첫째 주",
|
||||
"first-week-contains-first-day": "첫 번째 주에는 올해의 첫날이 포함됩니다"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "~로 노트 복제",
|
||||
"help_on_links": "링크에 대한 도움말",
|
||||
"notes_to_clone": "노트 클론 생성",
|
||||
"target_parent_note": "부모 노트 타겟",
|
||||
"search_for_note_by_its_name": "이름으로 노트 검색하기"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,7 +1330,8 @@
|
||||
"placeholder": "Wpisz tutaj treść swojej notatki kodowej..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Wpisz tutaj treść swojej notatki..."
|
||||
"placeholder": "Wpisz tutaj treść swojej notatki...",
|
||||
"auto-detect-language": "Wykryto automatycznie"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Otwórz notatkę, wpisując jej tytuł w poniższe pole lub wybierz notatkę z drzewa.",
|
||||
@@ -2014,9 +2015,6 @@
|
||||
"move-to-available-launchers": "Przenieś do dostępnych programów uruchamiających",
|
||||
"duplicate-launcher": "Duplikuj program uruchamiający <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Wykryto automatycznie"
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "Bloki kodu",
|
||||
"description": "Kontroluje podświetlanie składni dla bloków kodu w notatkach tekstowych, notatki kodowe nie będą miały wpływu.",
|
||||
|
||||
@@ -954,7 +954,8 @@
|
||||
"placeholder": "Digite o conteúdo da sua nota de código aqui…"
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Digite o conteúdo da sua nota aqui…"
|
||||
"placeholder": "Digite o conteúdo da sua nota aqui…",
|
||||
"auto-detect-language": "Detetado automaticamente"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Abra uma nota a digitar o título da nota no campo abaixo ou escolha uma nota na árvore.",
|
||||
@@ -1768,9 +1769,6 @@
|
||||
"move-to-available-launchers": "Mover para lançadores disponíveis",
|
||||
"duplicate-launcher": "Duplicar o lançador <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Detetado automaticamente"
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "Blocos de Código",
|
||||
"description": "Controla o destaque de sintaxe para blocos de código dentro de notas de texto, notas de código não serão afetadas.",
|
||||
|
||||
@@ -1191,7 +1191,8 @@
|
||||
"placeholder": "Digite o conteúdo da sua nota de código aqui…"
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Digite o conteúdo da sua nota aqui…"
|
||||
"placeholder": "Digite o conteúdo da sua nota aqui…",
|
||||
"auto-detect-language": "Detectado automaticamente"
|
||||
},
|
||||
"empty": {
|
||||
"search_placeholder": "buscar uma nota pelo nome",
|
||||
@@ -1689,9 +1690,6 @@
|
||||
"move-to-available-launchers": "Mover para lançadores disponíveis",
|
||||
"duplicate-launcher": "Duplicar o lançador <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Detectado automaticamente"
|
||||
},
|
||||
"highlighting": {
|
||||
"title": "Blocos de Código",
|
||||
"description": "Controla o destaque de sintaxe para blocos de código dentro de notas de texto, notas de código não serão afetadas.",
|
||||
|
||||
@@ -491,7 +491,9 @@
|
||||
"placeholder": "Scrieți conținutul notiței de cod aici..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Scrieți conținutul notiței aici..."
|
||||
"placeholder": "Scrieți conținutul notiței aici...",
|
||||
"auto-detect-language": "Automat",
|
||||
"keeps-crashing": "Componenta de editare se blochează în continuu. Încercați să reporniți Trilium. Dacă problema persistă, luați în considerare să raportați această problemă."
|
||||
},
|
||||
"edited_notes": {
|
||||
"deleted": "(șters)",
|
||||
@@ -1626,10 +1628,6 @@
|
||||
"move-to-visible-launchers": "Mută în Lansatoare vizibile",
|
||||
"reset": "Resetează"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Automat",
|
||||
"keeps-crashing": "Componenta de editare se blochează în continuu. Încercați să reporniți Trilium. Dacă problema persistă, luați în considerare să raportați această problemă."
|
||||
},
|
||||
"highlighting": {
|
||||
"color-scheme": "Temă de culori",
|
||||
"description": "Controlează evidențierea de sintaxă pentru blocurile de cod în interiorul notițelor text, notițele de tip cod nu vor fi afectate de aceste setări.",
|
||||
|
||||
@@ -726,9 +726,6 @@
|
||||
"title": "Блоки кода",
|
||||
"description": "Управляет подсветкой синтаксиса для блоков кода внутри текстовых заметок. Заметки с типом \"Код\" не будут затронуты."
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Определен автоматически"
|
||||
},
|
||||
"launcher_context_menu": {
|
||||
"reset": "Сбросить",
|
||||
"add-spacer": "Добавить разделитель",
|
||||
@@ -2026,7 +2023,8 @@
|
||||
"placeholder": "Введите содержимое для заметки с кодом..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Введите содержимое для заметки..."
|
||||
"placeholder": "Введите содержимое для заметки...",
|
||||
"auto-detect-language": "Определен автоматически"
|
||||
},
|
||||
"hoisted_note": {
|
||||
"confirm_unhoisting": "Запрошенная заметка «{{requestedNote}}» находится за пределами поддерева закрепленной заметки \"{{hoistedNote}}\", и для доступа к ней необходимо снять закрепление. Открепить заметку?"
|
||||
|
||||
@@ -734,7 +734,8 @@
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"relation": "關聯",
|
||||
"backlink_one": "{{count}} 個反連結"
|
||||
"backlink_one": "{{count}} 個反連結",
|
||||
"backlink_other": "{{count}} 個反連結"
|
||||
},
|
||||
"mobile_detail_menu": {
|
||||
"insert_child_note": "插入子筆記",
|
||||
@@ -983,7 +984,9 @@
|
||||
"placeholder": "在這裡輸入您的程式碼筆記內容…"
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "在這裡輸入您的筆記內容…"
|
||||
"placeholder": "在這裡輸入您的筆記內容…",
|
||||
"auto-detect-language": "自動檢測",
|
||||
"keeps-crashing": "編輯元件持續發生崩潰。請嘗試重新啟動 Trilium。若問題仍存在,請考慮提交錯誤報告。"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "透過在下面的輸入框中輸入筆記標題或在樹中選擇筆記來打開筆記。",
|
||||
@@ -1428,7 +1431,7 @@
|
||||
"import-into-note": "匯入至筆記",
|
||||
"apply-bulk-actions": "套用批次操作",
|
||||
"converted-to-attachments": "{{count}} 個筆記已被轉換為附件。",
|
||||
"convert-to-attachment-confirm": "確定要將所選的筆記轉換為其父級筆記的附件嗎?",
|
||||
"convert-to-attachment-confirm": "確定要將所選的筆記轉換為其父級筆記的附件嗎?此操作僅適用於圖像筆記,其他筆記將被跳過。",
|
||||
"duplicate": "複製副本",
|
||||
"open-in-popup": "快速編輯",
|
||||
"archive": "封存",
|
||||
@@ -1619,10 +1622,6 @@
|
||||
"move-to-available-launchers": "移動至可用啟動器",
|
||||
"duplicate-launcher": "複製啟動器 <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "自動檢測",
|
||||
"keeps-crashing": "編輯元件持續發生崩潰。請嘗試重新啟動 Trilium。若問題仍存在,請考慮提交錯誤報告。"
|
||||
},
|
||||
"highlighting": {
|
||||
"description": "控制文字筆記程式碼區塊中的語法高亮,程式碼筆記不會受到影響。",
|
||||
"color-scheme": "配色方案",
|
||||
@@ -1673,7 +1672,8 @@
|
||||
"open_note_in_new_tab": "在新分頁中打開筆記",
|
||||
"open_note_in_new_split": "在新頁面分割中打開筆記",
|
||||
"open_note_in_new_window": "在新視窗中打開筆記",
|
||||
"open_note_in_popup": "快速編輯"
|
||||
"open_note_in_popup": "快速編輯",
|
||||
"open_note_in_other_split": "在另一個頁面分割中打開筆記"
|
||||
},
|
||||
"zen_mode": {
|
||||
"button_exit": "退出禪模式"
|
||||
@@ -1953,7 +1953,8 @@
|
||||
"button_title": "將圖表匯出為 PNG"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "無法將圖表匯出為 PNG。"
|
||||
"export_to_png": "無法將圖表匯出為 PNG。",
|
||||
"export_to_svg": "此绘图无法导出为SVG格式。"
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "外觀",
|
||||
|
||||
@@ -130,9 +130,6 @@
|
||||
"move-to-available-launchers": "Перейти до доступних лаунчерів",
|
||||
"duplicate-launcher": "Дублікат програми запуску <kbd data-command=\"duplicateSubtree\">"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Автовизначено"
|
||||
},
|
||||
"highlighting": {
|
||||
"color-scheme": "Схема кольорів",
|
||||
"title": "Блоки коду",
|
||||
@@ -1076,7 +1073,8 @@
|
||||
"placeholder": "Введіть тут вміст вашої нотатки з кодом..."
|
||||
},
|
||||
"editable_text": {
|
||||
"placeholder": "Введіть тут вміст вашої нотатки..."
|
||||
"placeholder": "Введіть тут вміст вашої нотатки...",
|
||||
"auto-detect-language": "Автовизначено"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Відкрийте нотатку, ввівши її заголовок в поле нижче, або виберіть нотатку в дереві.",
|
||||
|
||||
@@ -16,4 +16,5 @@ body.zen div.read-only-note-info-bar-widget {
|
||||
:root div.read-only-note-info-bar-widget button {
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
69
apps/client/src/widgets/Toast.css
Normal file
69
apps/client/src/widgets/Toast.css
Normal file
@@ -0,0 +1,69 @@
|
||||
#toast-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 20px;
|
||||
pointer-events: none;
|
||||
contain: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
--bs-toast-bg: var(--accented-background-color);
|
||||
--bs-toast-color: var(--main-text-color);
|
||||
z-index: 9999999999 !important;
|
||||
pointer-events: all;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-header {
|
||||
background-color: var(--more-accented-background-color) !important;
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
white-space: preserve-breaks;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast.no-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.toast.no-title .toast-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);
|
||||
}
|
||||
|
||||
.toast.no-title .toast-body {
|
||||
padding-inline-start: 0;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.toast.no-title .toast-header {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
.toast {
|
||||
.toast-buttons {
|
||||
padding: 0 1em 1em 1em;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
background-color: var(--toast-text-color) !important;
|
||||
height: 4px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
}
|
||||
|
||||
75
apps/client/src/widgets/Toast.tsx
Normal file
75
apps/client/src/widgets/Toast.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import "./Toast.css";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
import { removeToastFromStore, ToastOptionsWithRequiredId, toasts } from "../services/toast";
|
||||
import Icon from "./react/Icon";
|
||||
import { RawHtmlBlock } from "./react/RawHtml";
|
||||
import Button from "./react/Button";
|
||||
|
||||
export default function ToastContainer() {
|
||||
return (
|
||||
<div id="toast-container">
|
||||
{toasts.value.map(toast => <Toast key={toast.id} {...toast} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toast({ id, title, timeout, progress, message, icon, buttons }: ToastOptionsWithRequiredId) {
|
||||
// Autohide.
|
||||
useEffect(() => {
|
||||
if (!timeout || timeout <= 0) return;
|
||||
const timerId = setTimeout(() => removeToastFromStore(id), timeout);
|
||||
return () => clearTimeout(timerId);
|
||||
}, [ id, timeout ]);
|
||||
|
||||
function dismissToast() {
|
||||
removeToastFromStore(id);
|
||||
}
|
||||
|
||||
const closeButton = (
|
||||
<button
|
||||
type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"
|
||||
onClick={dismissToast}
|
||||
/>
|
||||
);
|
||||
const toastIcon = <Icon icon={icon.startsWith("bx ") ? icon : `bx bx-${icon}`} />;
|
||||
|
||||
return (
|
||||
<div
|
||||
class={clsx("toast", !title && "no-title")}
|
||||
role="alert" aria-live="assertive" aria-atomic="true"
|
||||
id={`toast-${id}`}
|
||||
>
|
||||
{title ? (
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto">
|
||||
{toastIcon}
|
||||
<span class="toast-title">{title}</span>
|
||||
</strong>
|
||||
{closeButton}
|
||||
</div>
|
||||
) : (
|
||||
<div class="toast-icon">{toastIcon}</div>
|
||||
)}
|
||||
|
||||
<RawHtmlBlock className="toast-body" html={message} />
|
||||
|
||||
{!title && <div class="toast-header">{closeButton}</div>}
|
||||
|
||||
{buttons && (
|
||||
<div class="toast-buttons">
|
||||
{buttons.map(({ text, onClick }) => (
|
||||
<Button text={text} onClick={() => onClick({ dismissToast })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
class="toast-progress"
|
||||
style={{ width: `${(progress ?? 0) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -169,29 +169,30 @@ export class TypedBasicWidget<T extends TypedComponent<any>> extends TypedCompon
|
||||
console.log("Got issue in widget ", this);
|
||||
console.error(e);
|
||||
|
||||
let noteId = this._noteId;
|
||||
const noteId = this._noteId;
|
||||
if (this._noteId) {
|
||||
froca.getNote(noteId, true).then((note) => {
|
||||
toastService.showPersistent({
|
||||
id: `custom-widget-failure-${noteId}`,
|
||||
title: t("toast.widget-error.title"),
|
||||
icon: "alert",
|
||||
icon: "bx bx-error-circle",
|
||||
message: t("toast.widget-error.message-custom", {
|
||||
id: noteId,
|
||||
title: note?.title,
|
||||
message: e.message
|
||||
message: e.message || e.toString()
|
||||
})
|
||||
});
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
toastService.showPersistent({
|
||||
id: `custom-widget-failure-unknown-${crypto.randomUUID()}`,
|
||||
title: t("toast.widget-error.title"),
|
||||
icon: "bx bx-error-circle",
|
||||
message: t("toast.widget-error.message-unknown", {
|
||||
message: e.message || e.toString()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
toastService.showPersistent({
|
||||
title: t("toast.widget-error.title"),
|
||||
icon: "alert",
|
||||
message: t("toast.widget-error.message-unknown", {
|
||||
message: e.message
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import FlexContainer from "./containers/flex_container.js";
|
||||
import OpenNoteButtonWidget from "./buttons/open_note_button_widget.js";
|
||||
import BookmarkFolderWidget from "./buttons/bookmark_folder.js";
|
||||
import froca from "../services/froca.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import type Component from "../components/component.js";
|
||||
|
||||
interface BookmarkButtonsSettings {
|
||||
titlePlacement?: string;
|
||||
}
|
||||
|
||||
export default class BookmarkButtons extends FlexContainer<Component> {
|
||||
private settings: BookmarkButtonsSettings;
|
||||
private noteIds: string[];
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super(isHorizontalLayout ? "row" : "column");
|
||||
|
||||
this.contentSized();
|
||||
this.settings = {};
|
||||
this.noteIds = [];
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
this.$widget.empty();
|
||||
this.children = [];
|
||||
this.noteIds = [];
|
||||
|
||||
const bookmarkParentNote = await froca.getNote("_lbBookmarks");
|
||||
|
||||
if (!bookmarkParentNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const note of await bookmarkParentNote.getChildNotes()) {
|
||||
this.noteIds.push(note.noteId);
|
||||
|
||||
let buttonWidget: OpenNoteButtonWidget | BookmarkFolderWidget = note.isLabelTruthy("bookmarkFolder")
|
||||
? new BookmarkFolderWidget(note)
|
||||
: new OpenNoteButtonWidget(note).class("launcher-button");
|
||||
|
||||
if (this.settings.titlePlacement) {
|
||||
if (!("settings" in buttonWidget)) {
|
||||
(buttonWidget as any).settings = {};
|
||||
}
|
||||
|
||||
(buttonWidget as any).settings.titlePlacement = this.settings.titlePlacement;
|
||||
}
|
||||
|
||||
this.child(buttonWidget);
|
||||
|
||||
this.$widget.append(buttonWidget.render());
|
||||
|
||||
buttonWidget.refreshIcon();
|
||||
}
|
||||
|
||||
utils.reloadTray();
|
||||
}
|
||||
|
||||
initialRenderCompleteEvent(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">): void {
|
||||
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId === "_lbBookmarks")) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
if (loadResults.getAttributeRows().find((attr) =>
|
||||
attr.type === "label" &&
|
||||
attr.name && ["iconClass", "workspaceIconClass", "bookmarkFolder"].includes(attr.name) &&
|
||||
attr.noteId && this.noteIds.includes(attr.noteId)
|
||||
)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import options from "../../services/options.js";
|
||||
import CommandButtonWidget from "./command_button.js";
|
||||
|
||||
export default class AiChatButton extends CommandButtonWidget {
|
||||
|
||||
constructor(note: FNote) {
|
||||
super();
|
||||
|
||||
this.command("createAiChat")
|
||||
.title(() => note.title)
|
||||
.icon(() => note.getIcon())
|
||||
.class("launcher-button");
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return options.get("aiEnabled") === "true";
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("aiEnabled")) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import RightDropdownButtonWidget from "./right_dropdown_button.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
const DROPDOWN_TPL = `
|
||||
<div class="bookmark-folder-widget">
|
||||
<style>
|
||||
.bookmark-folder-widget {
|
||||
min-width: 400px;
|
||||
max-height: 500px;
|
||||
padding: 7px 15px 0 15px;
|
||||
font-size: 1.2rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget ul {
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget .note-link {
|
||||
display: block;
|
||||
padding: 5px 10px 5px 5px;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget .note-link:hover {
|
||||
background-color: var(--accented-background-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dropdown-menu .bookmark-folder-widget a:hover {
|
||||
text-decoration: none;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget li .note-link {
|
||||
padding-inline-start: 35px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="parent-note"></div>
|
||||
|
||||
<ul class="children-notes"></ul>
|
||||
</div>`;
|
||||
|
||||
interface LinkOptions {
|
||||
showTooltip: boolean;
|
||||
showNoteIcon: boolean;
|
||||
}
|
||||
|
||||
export default class BookmarkFolderWidget extends RightDropdownButtonWidget {
|
||||
private note: FNote;
|
||||
private $parentNote!: JQuery<HTMLElement>;
|
||||
private $childrenNotes!: JQuery<HTMLElement>;
|
||||
declare $dropdownContent: JQuery<HTMLElement>;
|
||||
|
||||
constructor(note: FNote) {
|
||||
super(utils.escapeHtml(note.title), note.getIcon(), DROPDOWN_TPL);
|
||||
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
doRender(): void {
|
||||
super.doRender();
|
||||
|
||||
this.$parentNote = this.$dropdownContent.find(".parent-note");
|
||||
this.$childrenNotes = this.$dropdownContent.find(".children-notes");
|
||||
}
|
||||
|
||||
async dropdownShown(): Promise<void> {
|
||||
this.$parentNote.empty();
|
||||
this.$childrenNotes.empty();
|
||||
|
||||
const linkOptions: LinkOptions = {
|
||||
showTooltip: false,
|
||||
showNoteIcon: true
|
||||
};
|
||||
|
||||
this.$parentNote.append((await linkService.createLink(this.note.noteId, linkOptions)).addClass("note-link"));
|
||||
|
||||
for (const childNote of await this.note.getChildNotes()) {
|
||||
this.$childrenNotes.append($("<li>").append((await linkService.createLink(childNote.noteId, linkOptions)).addClass("note-link")));
|
||||
}
|
||||
}
|
||||
|
||||
refreshIcon(): void {}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import froca from "../../services/froca.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import CommandButtonWidget from "./command_button.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
|
||||
export type ButtonNoteIdProvider = () => string;
|
||||
|
||||
export default class ButtonFromNoteWidget extends CommandButtonWidget {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.settings.buttonNoteIdProvider = null;
|
||||
}
|
||||
|
||||
buttonNoteIdProvider(provider: ButtonNoteIdProvider) {
|
||||
this.settings.buttonNoteIdProvider = provider;
|
||||
return this;
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
this.updateIcon();
|
||||
}
|
||||
|
||||
updateIcon() {
|
||||
if (!this.settings.buttonNoteIdProvider) {
|
||||
console.error(`buttonNoteId for '${this.componentId}' is not defined.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonNoteId = this.settings.buttonNoteIdProvider();
|
||||
|
||||
if (!buttonNoteId) {
|
||||
console.error(`buttonNoteId for '${this.componentId}' is not defined.`);
|
||||
return;
|
||||
}
|
||||
|
||||
froca.getNote(buttonNoteId).then((note) => {
|
||||
const icon = note?.getIcon();
|
||||
if (icon) {
|
||||
this.settings.icon = icon;
|
||||
}
|
||||
|
||||
this.refreshIcon();
|
||||
});
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
// TODO: this seems incorrect
|
||||
//@ts-ignore
|
||||
const buttonNote = froca.getNoteFromCache(this.buttonNoteIdProvider());
|
||||
|
||||
if (!buttonNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadResults.getAttributeRows(this.componentId).find((attr) => attr.type === "label" && attr.name === "iconClass" && attributeService.isAffecting(attr, buttonNote))) {
|
||||
this.updateIcon();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import dateNoteService from "../../services/date_notes.js";
|
||||
import server from "../../services/server.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import RightDropdownButtonWidget from "./right_dropdown_button.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import options from "../../services/options.js";
|
||||
import { Dropdown } from "bootstrap";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { dayjs, type Dayjs } from "@triliumnext/commons";
|
||||
import "../../stylesheets/calendar.css";
|
||||
import type { AttributeRow, OptionDefinitions } from "@triliumnext/commons";
|
||||
|
||||
const MONTHS = [
|
||||
t("calendar.january"),
|
||||
t("calendar.february"),
|
||||
t("calendar.march"),
|
||||
t("calendar.april"),
|
||||
t("calendar.may"),
|
||||
t("calendar.june"),
|
||||
t("calendar.july"),
|
||||
t("calendar.august"),
|
||||
t("calendar.september"),
|
||||
t("calendar.october"),
|
||||
t("calendar.november"),
|
||||
t("calendar.december")
|
||||
];
|
||||
|
||||
const DROPDOWN_TPL = `
|
||||
<div class="calendar-dropdown-widget">
|
||||
<style>
|
||||
.calendar-dropdown-widget {
|
||||
width: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="calendar-header">
|
||||
<div class="calendar-month-selector">
|
||||
<button class="calendar-btn tn-tool-button bx bx-chevron-left" data-calendar-toggle="previous"></button>
|
||||
|
||||
<button class="btn dropdown-toggle select-button" type="button"
|
||||
data-bs-toggle="dropdown" data-bs-auto-close="true"
|
||||
aria-expanded="false"
|
||||
data-calendar-input="month"></button>
|
||||
<ul class="dropdown-menu" data-calendar-input="month-list">
|
||||
${Object.entries(MONTHS)
|
||||
.map(([i, month]) => `<li><button class="dropdown-item" data-value=${i}>${month}</button></li>`)
|
||||
.join("")}
|
||||
</ul>
|
||||
|
||||
<button class="calendar-btn tn-tool-button bx bx-chevron-right" data-calendar-toggle="next"></button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-year-selector">
|
||||
<button class="calendar-btn tn-tool-button bx bx-chevron-left" data-calendar-toggle="previousYear"></button>
|
||||
|
||||
<input type="number" min="1900" max="2999" step="1" data-calendar-input="year" />
|
||||
|
||||
<button class="calendar-btn tn-tool-button bx bx-chevron-right" data-calendar-toggle="nextYear"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-week"></div>
|
||||
<div class="calendar-body" data-calendar-area="month"></div>
|
||||
</div>`;
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
t("calendar.sun"),
|
||||
t("calendar.mon"),
|
||||
t("calendar.tue"),
|
||||
t("calendar.wed"),
|
||||
t("calendar.thu"),
|
||||
t("calendar.fri"),
|
||||
t("calendar.sat")
|
||||
];
|
||||
|
||||
interface DateNotesForMonth {
|
||||
[date: string]: string;
|
||||
}
|
||||
|
||||
interface WeekCalculationOptions {
|
||||
firstWeekType: number;
|
||||
minDaysInFirstWeek: number;
|
||||
}
|
||||
|
||||
export default class CalendarWidget extends RightDropdownButtonWidget {
|
||||
private $month!: JQuery<HTMLElement>;
|
||||
private $weekHeader!: JQuery<HTMLElement>;
|
||||
private $monthSelect!: JQuery<HTMLElement>;
|
||||
private $yearSelect!: JQuery<HTMLElement>;
|
||||
private $next!: JQuery<HTMLElement>;
|
||||
private $previous!: JQuery<HTMLElement>;
|
||||
private $nextYear!: JQuery<HTMLElement>;
|
||||
private $previousYear!: JQuery<HTMLElement>;
|
||||
private monthDropdown!: Dropdown;
|
||||
// stored in ISO 1–7
|
||||
private firstDayOfWeekISO!: number;
|
||||
private weekCalculationOptions!: WeekCalculationOptions;
|
||||
private activeDate: Dayjs | null = null;
|
||||
private todaysDate!: Dayjs;
|
||||
private date!: Dayjs;
|
||||
private weekNoteEnable: boolean = false;
|
||||
private weekNotes: string[] = [];
|
||||
|
||||
constructor(title: string = "", icon: string = "") {
|
||||
super(title, icon, DROPDOWN_TPL, "calendar-dropdown-menu");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
this.$month = this.$dropdownContent.find('[data-calendar-area="month"]');
|
||||
this.$weekHeader = this.$dropdownContent.find(".calendar-week");
|
||||
|
||||
this.manageFirstDayOfWeek();
|
||||
this.initWeekCalculation();
|
||||
|
||||
// Month navigation
|
||||
this.$monthSelect = this.$dropdownContent.find('[data-calendar-input="month"]');
|
||||
this.$monthSelect.on("show.bs.dropdown", (e) => {
|
||||
// Don't trigger dropdownShown() at widget level when the month selection dropdown is shown, since it would cause a redundant refresh.
|
||||
e.stopPropagation();
|
||||
});
|
||||
this.monthDropdown = Dropdown.getOrCreateInstance(this.$monthSelect[0]);
|
||||
this.$dropdownContent.find('[data-calendar-input="month-list"] button').on("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const value = target.dataset.value;
|
||||
if (value) {
|
||||
this.date = this.date.month(parseInt(value));
|
||||
this.createMonth();
|
||||
}
|
||||
});
|
||||
|
||||
this.$next = this.$dropdownContent.find('[data-calendar-toggle="next"]');
|
||||
this.$next.on("click", () => {
|
||||
this.date = this.date.add(1, 'month');
|
||||
this.createMonth();
|
||||
});
|
||||
this.$previous = this.$dropdownContent.find('[data-calendar-toggle="previous"]');
|
||||
this.$previous.on("click", () => {
|
||||
this.date = this.date.subtract(1, 'month');
|
||||
this.createMonth();
|
||||
});
|
||||
|
||||
// Year navigation
|
||||
this.$yearSelect = this.$dropdownContent.find('[data-calendar-input="year"]');
|
||||
this.$yearSelect.on("input", (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
this.date = this.date.year(parseInt(target.value));
|
||||
this.createMonth();
|
||||
});
|
||||
|
||||
this.$nextYear = this.$dropdownContent.find('[data-calendar-toggle="nextYear"]');
|
||||
this.$nextYear.on("click", () => {
|
||||
this.date = this.date.add(1, 'year');
|
||||
this.createMonth();
|
||||
});
|
||||
|
||||
this.$previousYear = this.$dropdownContent.find('[data-calendar-toggle="previousYear"]');
|
||||
this.$previousYear.on("click", () => {
|
||||
this.date = this.date.subtract(1, 'year');
|
||||
this.createMonth();
|
||||
});
|
||||
|
||||
// Date click
|
||||
this.$dropdownContent.on("click", ".calendar-date", async (ev) => {
|
||||
const date = $(ev.target).closest(".calendar-date").attr("data-calendar-date");
|
||||
if (date) {
|
||||
const note = await dateNoteService.getDayNote(date);
|
||||
if (note) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
|
||||
this.dropdown?.hide();
|
||||
} else {
|
||||
toastService.showError(t("calendar.cannot_find_day_note"));
|
||||
}
|
||||
}
|
||||
ev.stopPropagation();
|
||||
});
|
||||
|
||||
// Week click
|
||||
this.$dropdownContent.on("click", ".calendar-week-number", async (ev) => {
|
||||
if (!this.weekNoteEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const week = $(ev.target).closest(".calendar-week-number").attr("data-calendar-week-number");
|
||||
|
||||
if (week) {
|
||||
const note = await dateNoteService.getWeekNote(week);
|
||||
|
||||
if (note) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
|
||||
this.dropdown?.hide();
|
||||
} else {
|
||||
toastService.showError(t("calendar.cannot_find_week_note"));
|
||||
}
|
||||
}
|
||||
|
||||
ev.stopPropagation();
|
||||
});
|
||||
|
||||
// Handle click events for the entire calendar widget
|
||||
this.$dropdownContent.on("click", (e) => {
|
||||
const $target = $(e.target);
|
||||
|
||||
// Keep dropdown open when clicking on month select button or year selector area
|
||||
if ($target.closest('.btn.dropdown-toggle.select-button').length) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide dropdown for all other cases
|
||||
this.monthDropdown.hide();
|
||||
// Prevent dismissing the calendar popup by clicking on an empty space inside it.
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
private async getWeekNoteEnable() {
|
||||
const noteId = await server.get<string[]>(`search/${encodeURIComponent('#calendarRoot')}`);
|
||||
if (noteId.length === 0) {
|
||||
this.weekNoteEnable = false;
|
||||
return;
|
||||
}
|
||||
const noteAttributes = await server.get<AttributeRow[]>(`notes/${noteId}/attributes`);
|
||||
this.weekNoteEnable = noteAttributes.some(a => a.name === 'enableWeekNote');
|
||||
}
|
||||
|
||||
// Store firstDayOfWeek as ISO (1–7)
|
||||
manageFirstDayOfWeek() {
|
||||
const rawFirstDayOfWeek = options.getInt("firstDayOfWeek") || 0;
|
||||
this.firstDayOfWeekISO = rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek;
|
||||
|
||||
let localeDaysOfWeek = [...DAYS_OF_WEEK];
|
||||
const shifted = localeDaysOfWeek.splice(0, rawFirstDayOfWeek);
|
||||
localeDaysOfWeek = ['', ...localeDaysOfWeek, ...shifted];
|
||||
this.$weekHeader.html(localeDaysOfWeek.map((el) => `<span>${el}</span>`).join(''));
|
||||
}
|
||||
|
||||
initWeekCalculation() {
|
||||
this.weekCalculationOptions = {
|
||||
firstWeekType: options.getInt("firstWeekOfYear") || 0,
|
||||
minDaysInFirstWeek: options.getInt("minDaysInFirstWeek") || 4
|
||||
};
|
||||
}
|
||||
|
||||
getWeekStartDate(date: Dayjs): Dayjs {
|
||||
const currentISO = date.isoWeekday();
|
||||
const diff = (currentISO - this.firstDayOfWeekISO + 7) % 7;
|
||||
return date.clone().subtract(diff, "day").startOf("day");
|
||||
}
|
||||
|
||||
getWeekNumber(date: Dayjs): number {
|
||||
const weekStart = this.getWeekStartDate(date);
|
||||
return weekStart.isoWeek();
|
||||
}
|
||||
|
||||
async dropdownShown() {
|
||||
await this.getWeekNoteEnable();
|
||||
this.weekNotes = await server.get<string[]>(`attribute-values/weekNote`);
|
||||
this.init(appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote") ?? null);
|
||||
}
|
||||
|
||||
init(activeDate: string | null) {
|
||||
this.activeDate = activeDate ? dayjs(`${activeDate}T12:00:00`) : null;
|
||||
this.todaysDate = dayjs();
|
||||
this.date = dayjs(this.activeDate || this.todaysDate).startOf('month');
|
||||
this.createMonth();
|
||||
}
|
||||
|
||||
createDay(dateNotesForMonth: DateNotesForMonth, num: number) {
|
||||
const $newDay = $("<a>")
|
||||
.addClass("calendar-date")
|
||||
.attr("data-calendar-date", this.date.local().format('YYYY-MM-DD'));
|
||||
const $date = $("<span>").html(String(num));
|
||||
const dateNoteId = dateNotesForMonth[this.date.local().format('YYYY-MM-DD')];
|
||||
|
||||
if (dateNoteId) {
|
||||
$newDay.addClass("calendar-date-exists").attr("data-href", `#root/${dateNoteId}`);
|
||||
}
|
||||
|
||||
if (this.date.isSame(this.activeDate, 'day')) $newDay.addClass("calendar-date-active");
|
||||
if (this.date.isSame(this.todaysDate, 'day')) $newDay.addClass("calendar-date-today");
|
||||
|
||||
$newDay.append($date);
|
||||
return $newDay;
|
||||
}
|
||||
|
||||
createWeekNumber(weekNumber: number) {
|
||||
const weekNoteId = this.date.local().format('YYYY-') + 'W' + String(weekNumber).padStart(2, '0');
|
||||
let $newWeekNumber;
|
||||
|
||||
if (this.weekNoteEnable) {
|
||||
$newWeekNumber = $("<a>").addClass("calendar-date");
|
||||
if (this.weekNotes.includes(weekNoteId)) {
|
||||
$newWeekNumber.addClass("calendar-date-exists").attr("data-href", `#root/${weekNoteId}`);
|
||||
}
|
||||
} else {
|
||||
$newWeekNumber = $("<span>").addClass("calendar-week-number-disabled");
|
||||
}
|
||||
|
||||
$newWeekNumber.addClass("calendar-week-number").attr("data-calendar-week-number", weekNoteId);
|
||||
$newWeekNumber.append($("<span>").html(String(weekNumber)));
|
||||
return $newWeekNumber;
|
||||
}
|
||||
|
||||
// Use isoWeekday() consistently
|
||||
private getPrevMonthDays(firstDayISO: number): { weekNumber: number, dates: Dayjs[] } {
|
||||
const prevMonthLastDay = this.date.subtract(1, 'month').endOf('month');
|
||||
const daysToAdd = (firstDayISO - this.firstDayOfWeekISO + 7) % 7;
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
const firstDay = this.date.startOf('month');
|
||||
const weekNumber = this.getWeekNumber(firstDay);
|
||||
|
||||
// Get dates from previous month
|
||||
for (let i = daysToAdd - 1; i >= 0; i--) {
|
||||
dates.push(prevMonthLastDay.subtract(i, 'day'));
|
||||
}
|
||||
|
||||
return { weekNumber, dates };
|
||||
}
|
||||
|
||||
private getNextMonthDays(lastDayISO: number): Dayjs[] {
|
||||
const nextMonthFirstDay = this.date.add(1, 'month').startOf('month');
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
const lastDayOfUserWeek = ((this.firstDayOfWeekISO + 6 - 1) % 7) + 1; // ISO wrap
|
||||
const daysToAdd = (lastDayOfUserWeek - lastDayISO + 7) % 7;
|
||||
|
||||
for (let i = 0; i < daysToAdd; i++) {
|
||||
dates.push(nextMonthFirstDay.add(i, 'day'));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
async createMonth() {
|
||||
const month = this.date.format('YYYY-MM');
|
||||
const dateNotesForMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${month}`);
|
||||
|
||||
this.$month.empty();
|
||||
|
||||
const firstDay = this.date.startOf('month');
|
||||
const firstDayISO = firstDay.isoWeekday();
|
||||
|
||||
// Previous month filler
|
||||
if (firstDayISO !== this.firstDayOfWeekISO) {
|
||||
const { weekNumber, dates } = this.getPrevMonthDays(firstDayISO);
|
||||
const prevMonth = this.date.subtract(1, 'month').format('YYYY-MM');
|
||||
const dateNotesForPrevMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${prevMonth}`);
|
||||
|
||||
const $weekNumber = this.createWeekNumber(weekNumber);
|
||||
this.$month.append($weekNumber);
|
||||
|
||||
dates.forEach(date => {
|
||||
const tempDate = this.date;
|
||||
this.date = date;
|
||||
const $day = this.createDay(dateNotesForPrevMonth, date.date());
|
||||
$day.addClass('calendar-date-prev-month');
|
||||
this.$month.append($day);
|
||||
this.date = tempDate;
|
||||
});
|
||||
}
|
||||
|
||||
const currentMonth = this.date.month();
|
||||
|
||||
// Main month
|
||||
while (this.date.month() === currentMonth) {
|
||||
const weekNumber = this.getWeekNumber(this.date);
|
||||
if (this.date.isoWeekday() === this.firstDayOfWeekISO) {
|
||||
const $weekNumber = this.createWeekNumber(weekNumber);
|
||||
this.$month.append($weekNumber);
|
||||
}
|
||||
|
||||
const $day = this.createDay(dateNotesForMonth, this.date.date());
|
||||
this.$month.append($day);
|
||||
this.date = this.date.add(1, 'day');
|
||||
}
|
||||
// while loop trips over and day is at 30/31, bring it back
|
||||
this.date = this.date.startOf('month').subtract(1, 'month');
|
||||
|
||||
// Add dates from next month
|
||||
const lastDayOfMonth = this.date.endOf('month');
|
||||
const lastDayISO = lastDayOfMonth.isoWeekday();
|
||||
const lastDayOfUserWeek = ((this.firstDayOfWeekISO + 6 - 1) % 7) + 1;
|
||||
|
||||
if (lastDayISO !== lastDayOfUserWeek) {
|
||||
const dates = this.getNextMonthDays(lastDayISO);
|
||||
const nextMonth = this.date.add(1, 'month').format('YYYY-MM');
|
||||
const dateNotesForNextMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${nextMonth}`);
|
||||
|
||||
dates.forEach(date => {
|
||||
const tempDate = this.date;
|
||||
this.date = date;
|
||||
const $day = this.createDay(dateNotesForNextMonth, date.date());
|
||||
$day.addClass('calendar-date-next-month');
|
||||
this.$month.append($day);
|
||||
this.date = tempDate;
|
||||
});
|
||||
}
|
||||
|
||||
this.$monthSelect.text(MONTHS[this.date.month()]);
|
||||
this.$yearSelect.val(this.date.year());
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
const WEEK_OPTIONS: (keyof OptionDefinitions)[] = [
|
||||
"firstDayOfWeek",
|
||||
"firstWeekOfYear",
|
||||
"minDaysInFirstWeek",
|
||||
];
|
||||
if (!WEEK_OPTIONS.some(opt => loadResults.getOptionNames().includes(opt))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.manageFirstDayOfWeek();
|
||||
this.initWeekCalculation();
|
||||
this.createMonth();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import keyboardActionsService from "../../services/keyboard_actions.js";
|
||||
import AbstractButtonWidget, { type AbstractButtonWidgetSettings } from "./abstract_button.js";
|
||||
import type { ButtonNoteIdProvider } from "./button_from_note.js";
|
||||
|
||||
let actions: ActionKeyboardShortcut[];
|
||||
|
||||
keyboardActionsService.getActions().then((as) => (actions = as));
|
||||
|
||||
// TODO: Is this actually used?
|
||||
export type ClickHandler = (widget: CommandButtonWidget, e: JQuery.ClickEvent<any, any, any, any>) => void;
|
||||
type CommandOrCallback = CommandNames | (() => CommandNames);
|
||||
|
||||
interface CommandButtonWidgetSettings extends AbstractButtonWidgetSettings {
|
||||
command?: CommandOrCallback;
|
||||
onClick?: ClickHandler;
|
||||
buttonNoteIdProvider?: ButtonNoteIdProvider | null;
|
||||
}
|
||||
|
||||
export default class CommandButtonWidget extends AbstractButtonWidget<CommandButtonWidgetSettings> {
|
||||
constructor() {
|
||||
super();
|
||||
this.settings = {
|
||||
titlePlacement: "right",
|
||||
title: null,
|
||||
icon: null,
|
||||
onContextMenu: null
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
if (this.settings.command) {
|
||||
this.$widget.on("click", () => {
|
||||
this.tooltip.hide();
|
||||
|
||||
if (this._command) {
|
||||
this.triggerCommand(this._command);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn(`Button widget '${this.componentId}' has no defined command`, this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
const title = super.getTitle();
|
||||
|
||||
const action = actions.find((act) => act.actionName === this._command);
|
||||
|
||||
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
|
||||
return `${title} (${action.effectiveShortcuts.join(", ")})`;
|
||||
} else {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
onClick(handler: ClickHandler) {
|
||||
this.settings.onClick = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
command(command: CommandOrCallback) {
|
||||
this.settings.command = command;
|
||||
return this;
|
||||
}
|
||||
|
||||
get _command() {
|
||||
return typeof this.settings.command === "function" ? this.settings.command() : this.settings.command;
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import utils from "../../services/utils.js";
|
||||
import contextMenu, { MenuCommandItem } from "../../menus/context_menu.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import ButtonFromNoteWidget from "./button_from_note.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import type { WebContents } from "electron";
|
||||
import link from "../../services/link.js";
|
||||
|
||||
export default class HistoryNavigationButton extends ButtonFromNoteWidget {
|
||||
private webContents?: WebContents;
|
||||
|
||||
constructor(launcherNote: FNote, command: string) {
|
||||
super();
|
||||
|
||||
this.title(() => launcherNote.title)
|
||||
.icon(() => launcherNote.getIcon())
|
||||
.command(() => command as CommandNames)
|
||||
.titlePlacement("right")
|
||||
.buttonNoteIdProvider(() => launcherNote.noteId)
|
||||
.onContextMenu((e) => { if (e) this.showContextMenu(e); })
|
||||
.class("launcher-button");
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
if (utils.isElectron()) {
|
||||
this.webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
|
||||
// without this, the history is preserved across frontend reloads
|
||||
this.webContents?.clearHistory();
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async showContextMenu(e: JQuery.ContextMenuEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.webContents || this.webContents.navigationHistory.length() < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let items: MenuCommandItem<string>[] = [];
|
||||
|
||||
const history = this.webContents.navigationHistory.getAllEntries();
|
||||
const activeIndex = this.webContents.navigationHistory.getActiveIndex();
|
||||
|
||||
for (const idx in history) {
|
||||
const { notePath } = link.parseNavigationStateFromUrl(history[idx].url);
|
||||
if (!notePath) continue;
|
||||
|
||||
const title = await treeService.getNotePathTitle(notePath);
|
||||
|
||||
items.push({
|
||||
title,
|
||||
command: idx,
|
||||
uiIcon:
|
||||
parseInt(idx) === activeIndex
|
||||
? "bx bx-radio-circle-marked" // compare with type coercion!
|
||||
: parseInt(idx) < activeIndex
|
||||
? "bx bx-left-arrow-alt"
|
||||
: "bx bx-right-arrow-alt"
|
||||
});
|
||||
}
|
||||
|
||||
items.reverse();
|
||||
|
||||
if (items.length > 20) {
|
||||
items = items.slice(0, 50);
|
||||
}
|
||||
|
||||
contextMenu.show({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items,
|
||||
selectMenuItemHandler: (item: MenuCommandItem<string>) => {
|
||||
if (item && item.command && this.webContents) {
|
||||
const idx = parseInt(item.command, 10);
|
||||
this.webContents.navigationHistory.goToIndex(idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
activeNoteChangedEvent() {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import AbstractLauncher from "./abstract_launcher.js";
|
||||
import dialogService from "../../../services/dialog.js";
|
||||
import appContext from "../../../components/app_context.js";
|
||||
import utils from "../../../services/utils.js";
|
||||
import linkContextMenuService from "../../../menus/link_context_menu.js";
|
||||
import type FNote from "../../../entities/fnote.js";
|
||||
|
||||
// we're intentionally displaying the launcher title and icon instead of the target,
|
||||
// e.g. you want to make launchers to 2 mermaid diagrams which both have mermaid icon (ok),
|
||||
// but on the launchpad you want them distinguishable.
|
||||
// for titles, the note titles may follow a different scheme than maybe desirable on the launchpad
|
||||
// another reason is the discrepancy between what user sees on the launchpad and in the config (esp. icons).
|
||||
// The only downside is more work in setting up the typical case
|
||||
// where you actually want to have both title and icon in sync, but for those cases there are bookmarks
|
||||
export default class NoteLauncher extends AbstractLauncher {
|
||||
constructor(launcherNote: FNote) {
|
||||
super(launcherNote);
|
||||
|
||||
this.title(() => this.launcherNote.title)
|
||||
.icon(() => this.launcherNote.getIcon())
|
||||
.onClick((widget, evt) => this.launch(evt))
|
||||
.onAuxClick((widget, evt) => this.launch(evt))
|
||||
.onContextMenu(async (evt) => {
|
||||
let targetNoteId = await Promise.resolve(this.getTargetNoteId());
|
||||
|
||||
if (!targetNoteId || !evt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoistedNoteId = this.getHoistedNoteId();
|
||||
|
||||
linkContextMenuService.openContextMenu(targetNoteId, evt, {}, hoistedNoteId);
|
||||
});
|
||||
}
|
||||
|
||||
async launch(evt?: JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent) {
|
||||
// await because subclass overrides can be async
|
||||
const targetNoteId = await this.getTargetNoteId();
|
||||
if (!targetNoteId || evt?.which === 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoistedNoteId = await this.getHoistedNoteId();
|
||||
if (!hoistedNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!evt) {
|
||||
// keyboard shortcut
|
||||
// TODO: Fix once tabManager is ported.
|
||||
//@ts-ignore
|
||||
await appContext.tabManager.openInSameTab(targetNoteId, hoistedNoteId);
|
||||
} else {
|
||||
const ctrlKey = utils.isCtrlKey(evt);
|
||||
const activate = evt.shiftKey ? true : false;
|
||||
|
||||
if ((evt.which === 1 && ctrlKey) || evt.which === 2) {
|
||||
// TODO: Fix once tabManager is ported.
|
||||
//@ts-ignore
|
||||
await appContext.tabManager.openInNewTab(targetNoteId, hoistedNoteId, activate);
|
||||
} else {
|
||||
// TODO: Fix once tabManager is ported.
|
||||
//@ts-ignore
|
||||
await appContext.tabManager.openInSameTab(targetNoteId, hoistedNoteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTargetNoteId(): void | string | Promise<string | undefined> {
|
||||
const targetNoteId = this.launcherNote.getRelationValue("target");
|
||||
|
||||
if (!targetNoteId) {
|
||||
dialogService.info(t("note_launcher.this_launcher_doesnt_define_target_note"));
|
||||
return;
|
||||
}
|
||||
|
||||
return targetNoteId;
|
||||
}
|
||||
|
||||
getHoistedNoteId() {
|
||||
return this.launcherNote.getRelationValue("hoistedNote") || appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
const shortcuts = this.launcherNote
|
||||
.getLabels("keyboardShortcut")
|
||||
.map((l) => l.value)
|
||||
.filter((v) => !!v)
|
||||
.join(", ");
|
||||
|
||||
let title = super.getTitle();
|
||||
if (shortcuts) {
|
||||
title += ` (${shortcuts})`;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type FNote from "../../../entities/fnote.js";
|
||||
import AbstractLauncher from "./abstract_launcher.js";
|
||||
|
||||
export default class ScriptLauncher extends AbstractLauncher {
|
||||
constructor(launcherNote: FNote) {
|
||||
super(launcherNote);
|
||||
|
||||
this.title(() => this.launcherNote.title)
|
||||
.icon(() => this.launcherNote.getIcon())
|
||||
.onClick(() => this.launch());
|
||||
}
|
||||
|
||||
async launch() {
|
||||
if (this.launcherNote.isLabelTruthy("scriptInLauncherContent")) {
|
||||
await this.launcherNote.executeScript();
|
||||
} else {
|
||||
const script = await this.launcherNote.getRelationTarget("script");
|
||||
if (script) {
|
||||
await script.executeScript();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import NoteLauncher from "./note_launcher.js";
|
||||
import dateNotesService from "../../../services/date_notes.js";
|
||||
import appContext from "../../../components/app_context.js";
|
||||
|
||||
export default class TodayLauncher extends NoteLauncher {
|
||||
async getTargetNoteId() {
|
||||
const todayNote = await dateNotesService.getTodayNote();
|
||||
|
||||
return todayNote?.noteId;
|
||||
}
|
||||
|
||||
getHoistedNoteId() {
|
||||
return appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import OnClickButtonWidget from "./onclick_button.js";
|
||||
import linkContextMenuService from "../../menus/link_context_menu.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
export default class OpenNoteButtonWidget extends OnClickButtonWidget {
|
||||
|
||||
private noteToOpen: FNote;
|
||||
|
||||
constructor(noteToOpen: FNote) {
|
||||
super();
|
||||
|
||||
this.noteToOpen = noteToOpen;
|
||||
|
||||
this.title(() => utils.escapeHtml(this.noteToOpen.title))
|
||||
.icon(() => this.noteToOpen.getIcon())
|
||||
.onClick((widget, evt) => this.launch(evt))
|
||||
.onAuxClick((widget, evt) => this.launch(evt))
|
||||
.onContextMenu((evt) => {
|
||||
if (evt) {
|
||||
linkContextMenuService.openContextMenu(this.noteToOpen.noteId, evt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async launch(evt: JQuery.ClickEvent | JQuery.TriggeredEvent | JQuery.ContextMenuEvent) {
|
||||
if (evt.which === 3) {
|
||||
return;
|
||||
}
|
||||
const hoistedNoteId = this.getHoistedNoteId();
|
||||
const ctrlKey = utils.isCtrlKey(evt);
|
||||
|
||||
if ((evt.which === 1 && ctrlKey) || evt.which === 2) {
|
||||
const activate = evt.shiftKey ? true : false;
|
||||
await appContext.tabManager.openInNewTab(this.noteToOpen.noteId, hoistedNoteId, activate);
|
||||
} else {
|
||||
await appContext.tabManager.openInSameTab(this.noteToOpen.noteId);
|
||||
}
|
||||
}
|
||||
|
||||
getHoistedNoteId() {
|
||||
return this.noteToOpen.getRelationValue("hoistedNote") || appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
}
|
||||
|
||||
initialRenderCompleteEvent() {
|
||||
// we trigger refresh above
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import protectedSessionHolder from "../../services/protected_session_holder.js";
|
||||
import CommandButtonWidget from "./command_button.js";
|
||||
|
||||
export default class ProtectedSessionStatusWidget extends CommandButtonWidget {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.class("launcher-button");
|
||||
|
||||
this.settings.icon = () => (protectedSessionHolder.isProtectedSessionAvailable() ? "bx-check-shield" : "bx-shield-quarter");
|
||||
|
||||
this.settings.title = () => (protectedSessionHolder.isProtectedSessionAvailable() ? t("protected_session_status.active") : t("protected_session_status.inactive"));
|
||||
|
||||
this.settings.command = () => (protectedSessionHolder.isProtectedSessionAvailable() ? "leaveProtectedSession" : "enterProtectedSession");
|
||||
}
|
||||
|
||||
protectedSessionStartedEvent() {
|
||||
this.refreshIcon();
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { handleRightToLeftPlacement } from "../../services/utils.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Tooltip, Dropdown } from "bootstrap";
|
||||
type PopoverPlacement = Tooltip.PopoverPlacement;
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="dropdown right-dropdown-widget">
|
||||
<button type="button" data-bs-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false"
|
||||
class="bx right-dropdown-button launcher-button">
|
||||
<div class="tooltip-trigger"></div>
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class RightDropdownButtonWidget extends BasicWidget {
|
||||
protected iconClass: string;
|
||||
protected title: string;
|
||||
protected dropdownTpl: string;
|
||||
protected settings: { titlePlacement: PopoverPlacement };
|
||||
protected $dropdownMenu!: JQuery<HTMLElement>;
|
||||
protected dropdown!: Dropdown;
|
||||
protected $tooltip!: JQuery<HTMLElement>;
|
||||
protected tooltip!: Tooltip;
|
||||
private dropdownClass?: string;
|
||||
public $dropdownContent!: JQuery<HTMLElement>;
|
||||
|
||||
constructor(title: string, iconClass: string, dropdownTpl: string, dropdownClass?: string) {
|
||||
super();
|
||||
|
||||
this.iconClass = iconClass;
|
||||
this.title = title;
|
||||
this.dropdownTpl = dropdownTpl;
|
||||
this.dropdownClass = dropdownClass;
|
||||
|
||||
this.settings = {
|
||||
titlePlacement: "right"
|
||||
};
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$dropdownMenu = this.$widget.find(".dropdown-menu");
|
||||
if (this.dropdownClass) {
|
||||
this.$dropdownMenu.addClass(this.dropdownClass);
|
||||
}
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], {
|
||||
popperConfig: {
|
||||
placement: this.settings.titlePlacement,
|
||||
}
|
||||
});
|
||||
|
||||
this.$tooltip = this.$widget.find(".tooltip-trigger").attr("title", this.title);
|
||||
this.tooltip = new Tooltip(this.$tooltip[0], {
|
||||
placement: handleRightToLeftPlacement(this.settings.titlePlacement),
|
||||
fallbackPlacements: [ handleRightToLeftPlacement(this.settings.titlePlacement) ]
|
||||
});
|
||||
|
||||
this.$widget
|
||||
.find(".right-dropdown-button")
|
||||
.addClass(this.iconClass)
|
||||
.on("click", () => this.tooltip.hide())
|
||||
.on("mouseenter", () => this.tooltip.show())
|
||||
.on("mouseleave", () => this.tooltip.hide());
|
||||
|
||||
this.$widget.on("show.bs.dropdown", async () => {
|
||||
await this.dropdownShown();
|
||||
|
||||
const rect = this.$dropdownMenu[0].getBoundingClientRect();
|
||||
const windowHeight = $(window).height() || 0;
|
||||
const pixelsToBottom = windowHeight - rect.bottom;
|
||||
|
||||
if (pixelsToBottom < 0) {
|
||||
this.$dropdownMenu.css("top", pixelsToBottom);
|
||||
}
|
||||
});
|
||||
|
||||
this.$dropdownContent = $(this.dropdownTpl);
|
||||
this.$widget.find(".dropdown-menu").append(this.$dropdownContent);
|
||||
}
|
||||
|
||||
// to be overridden
|
||||
async dropdownShown(): Promise<void> {}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import CalendarWidget from "../buttons/calendar.js";
|
||||
import SpacerWidget from "../spacer.js";
|
||||
import BookmarkButtons from "../bookmark_buttons.js";
|
||||
import ProtectedSessionStatusWidget from "../buttons/protected_session_status.js";
|
||||
import SyncStatusWidget from "../sync_status.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import NoteLauncher from "../buttons/launcher/note_launcher.js";
|
||||
import ScriptLauncher from "../buttons/launcher/script_launcher.js";
|
||||
import CommandButtonWidget from "../buttons/command_button.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import TodayLauncher from "../buttons/launcher/today_launcher.js";
|
||||
import HistoryNavigationButton from "../buttons/history_navigation.js";
|
||||
import QuickSearchLauncherWidget from "../quick_search_launcher.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import AiChatButton from "../buttons/ai_chat_button.js";
|
||||
|
||||
interface InnerWidget extends BasicWidget {
|
||||
settings?: {
|
||||
titlePlacement: "bottom";
|
||||
};
|
||||
}
|
||||
|
||||
export default class LauncherWidget extends BasicWidget {
|
||||
private innerWidget!: InnerWidget;
|
||||
private isHorizontalLayout: boolean;
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super();
|
||||
|
||||
this.isHorizontalLayout = isHorizontalLayout;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.innerWidget.isEnabled();
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = this.innerWidget.render();
|
||||
}
|
||||
|
||||
async initLauncher(note: FNote) {
|
||||
if (note.type !== "launcher") {
|
||||
throw new Error(`Note '${note.noteId}' '${note.title}' is not a launcher even though it's in the launcher subtree`);
|
||||
}
|
||||
|
||||
if (!utils.isDesktop() && note.isLabelTruthy("desktopOnly")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const launcherType = note.getLabelValue("launcherType");
|
||||
|
||||
if (glob.TRILIUM_SAFE_MODE && launcherType === "customWidget") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let widget: BasicWidget;
|
||||
if (launcherType === "command") {
|
||||
widget = this.initCommandLauncherWidget(note).class("launcher-button");
|
||||
} else if (launcherType === "note") {
|
||||
widget = new NoteLauncher(note).class("launcher-button");
|
||||
} else if (launcherType === "script") {
|
||||
widget = new ScriptLauncher(note).class("launcher-button");
|
||||
} else if (launcherType === "customWidget") {
|
||||
widget = await this.initCustomWidget(note);
|
||||
} else if (launcherType === "builtinWidget") {
|
||||
widget = this.initBuiltinWidget(note);
|
||||
} else {
|
||||
throw new Error(`Unrecognized launcher type '${launcherType}' for launcher '${note.noteId}' title '${note.title}'`);
|
||||
}
|
||||
|
||||
if (!widget) {
|
||||
throw new Error(`Unknown initialization error for note '${note.noteId}', title '${note.title}'`);
|
||||
}
|
||||
|
||||
this.child(widget);
|
||||
this.innerWidget = widget as InnerWidget;
|
||||
if (this.isHorizontalLayout && this.innerWidget.settings) {
|
||||
this.innerWidget.settings.titlePlacement = "bottom";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
initCommandLauncherWidget(note: FNote) {
|
||||
return new CommandButtonWidget()
|
||||
.title(() => note.title)
|
||||
.icon(() => note.getIcon())
|
||||
.command(() => note.getLabelValue("command") as CommandNames);
|
||||
}
|
||||
|
||||
async initCustomWidget(note: FNote) {
|
||||
const widget = await note.getRelationTarget("widget");
|
||||
|
||||
if (widget) {
|
||||
return await widget.executeScript();
|
||||
} else {
|
||||
throw new Error(`Custom widget of launcher '${note.noteId}' '${note.title}' is not defined.`);
|
||||
}
|
||||
}
|
||||
|
||||
initBuiltinWidget(note: FNote) {
|
||||
const builtinWidget = note.getLabelValue("builtinWidget");
|
||||
switch (builtinWidget) {
|
||||
case "calendar":
|
||||
return new CalendarWidget(note.title, note.getIcon());
|
||||
case "spacer":
|
||||
// || has to be inside since 0 is a valid value
|
||||
const baseSize = parseInt(note.getLabelValue("baseSize") || "40");
|
||||
const growthFactor = parseInt(note.getLabelValue("growthFactor") || "100");
|
||||
|
||||
return new SpacerWidget(baseSize, growthFactor);
|
||||
case "bookmarks":
|
||||
return new BookmarkButtons(this.isHorizontalLayout);
|
||||
case "protectedSession":
|
||||
return new ProtectedSessionStatusWidget();
|
||||
case "syncStatus":
|
||||
return new SyncStatusWidget();
|
||||
case "backInHistoryButton":
|
||||
return new HistoryNavigationButton(note, "backInNoteHistory");
|
||||
case "forwardInHistoryButton":
|
||||
return new HistoryNavigationButton(note, "forwardInNoteHistory");
|
||||
case "todayInJournal":
|
||||
return new TodayLauncher(note);
|
||||
case "quickSearch":
|
||||
return new QuickSearchLauncherWidget(this.isHorizontalLayout);
|
||||
case "aiChatLauncher":
|
||||
return new AiChatButton(note);
|
||||
default:
|
||||
throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import FlexContainer from "./flex_container.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import appContext, { type EventData } from "../../components/app_context.js";
|
||||
import LauncherWidget from "./launcher.js";
|
||||
import utils from "../../services/utils.js";
|
||||
|
||||
export default class LauncherContainer extends FlexContainer<LauncherWidget> {
|
||||
private isHorizontalLayout: boolean;
|
||||
|
||||
constructor(isHorizontalLayout: boolean) {
|
||||
super(isHorizontalLayout ? "row" : "column");
|
||||
|
||||
this.id("launcher-container");
|
||||
this.filling();
|
||||
this.isHorizontalLayout = isHorizontalLayout;
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
await froca.initializedPromise;
|
||||
|
||||
const visibleLaunchersRootId = utils.isMobile() ? "_lbMobileVisibleLaunchers" : "_lbVisibleLaunchers";
|
||||
const visibleLaunchersRoot = await froca.getNote(visibleLaunchersRootId, true);
|
||||
|
||||
if (!visibleLaunchersRoot) {
|
||||
console.log("Visible launchers root note doesn't exist.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newChildren: LauncherWidget[] = [];
|
||||
|
||||
for (const launcherNote of await visibleLaunchersRoot.getChildNotes()) {
|
||||
try {
|
||||
const launcherWidget = new LauncherWidget(this.isHorizontalLayout);
|
||||
const success = await launcherWidget.initLauncher(launcherNote);
|
||||
|
||||
if (success) {
|
||||
newChildren.push(launcherWidget);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
this.children = [];
|
||||
this.child(...newChildren);
|
||||
|
||||
this.$widget.empty();
|
||||
this.renderChildren();
|
||||
|
||||
await this.handleEventInChildren("initialRenderComplete", {});
|
||||
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
|
||||
if (activeContext) {
|
||||
await this.handleEvent("setNoteContext", {
|
||||
noteContext: activeContext
|
||||
});
|
||||
|
||||
if (activeContext.notePath) {
|
||||
await this.handleEvent("noteSwitched", {
|
||||
noteContext: activeContext,
|
||||
notePath: activeContext.notePath
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.getBranchRows().find((branch) => branch.parentNoteId && froca.getNoteFromCache(branch.parentNoteId)?.isLaunchBarConfig())) {
|
||||
// changes in note placement require reload of all launchers, all other changes are handled by individual
|
||||
// launchers
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import { t } from "../../services/i18n";
|
||||
import { useState } from "preact/hooks";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { isValidElement, type VNode } from "preact";
|
||||
import { RawHtmlBlock } from "../react/RawHtml";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title?: string;
|
||||
message?: string | HTMLElement;
|
||||
message?: MessageType;
|
||||
callback?: ConfirmDialogCallback;
|
||||
isConfirmDeleteNoteBox?: boolean;
|
||||
isConfirmDeleteNoteBox?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog() {
|
||||
@@ -20,7 +22,7 @@ export default function ConfirmDialog() {
|
||||
function showDialog(title: string | null, message: MessageType, callback: ConfirmDialogCallback, isConfirmDeleteNoteBox: boolean) {
|
||||
setOpts({
|
||||
title: title ?? undefined,
|
||||
message: (typeof message === "object" && "length" in message ? message[0] : message),
|
||||
message,
|
||||
callback,
|
||||
isConfirmDeleteNoteBox
|
||||
});
|
||||
@@ -30,7 +32,7 @@ export default function ConfirmDialog() {
|
||||
useTriliumEvent("showConfirmDialog", ({ message, callback }) => showDialog(null, message, callback, false));
|
||||
useTriliumEvent("showConfirmDeleteNoteBoxWithNoteDialog", ({ title, callback }) => showDialog(title, t("confirm.are_you_sure_remove_note", { title: title }), callback, true));
|
||||
|
||||
return (
|
||||
return (
|
||||
<Modal
|
||||
className="confirm-dialog"
|
||||
title={opts?.title ?? t("confirm.confirmation")}
|
||||
@@ -57,9 +59,10 @@ export default function ConfirmDialog() {
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
{!opts?.message || typeof opts?.message === "string"
|
||||
? <div>{(opts?.message as string) ?? ""}</div>
|
||||
: <div dangerouslySetInnerHTML={{ __html: opts?.message.outerHTML ?? "" }} />}
|
||||
{isValidElement(opts?.message)
|
||||
? opts?.message
|
||||
: <RawHtmlBlock html={opts?.message} />
|
||||
}
|
||||
|
||||
{opts?.isConfirmDeleteNoteBox && (
|
||||
<FormCheckbox
|
||||
@@ -74,7 +77,7 @@ export default function ConfirmDialog() {
|
||||
|
||||
export type ConfirmDialogResult = false | ConfirmDialogOptions;
|
||||
export type ConfirmDialogCallback = (val?: ConfirmDialogResult) => void;
|
||||
type MessageType = string | HTMLElement | JQuery<HTMLElement>;
|
||||
export type MessageType = string | HTMLElement | JQuery<HTMLElement> | VNode;
|
||||
|
||||
export interface ConfirmDialogOptions {
|
||||
confirmed: boolean;
|
||||
|
||||
@@ -6,7 +6,7 @@ import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import Modal from "../react/Modal";
|
||||
import "./export.css";
|
||||
import ws from "../../services/ws";
|
||||
import toastService, { ToastOptions } from "../../services/toast";
|
||||
import toastService, { type ToastOptionsWithRequiredId } from "../../services/toast";
|
||||
import utils from "../../services/utils";
|
||||
import open from "../../services/open";
|
||||
import froca from "../../services/froca";
|
||||
@@ -132,11 +132,11 @@ function exportBranch(branchId: string, type: string, format: string, version: s
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
function makeToast(id: string, message: string): ToastOptions {
|
||||
function makeToast(id: string, message: string): ToastOptionsWithRequiredId {
|
||||
return {
|
||||
id: id,
|
||||
id,
|
||||
title: t("export.export_status"),
|
||||
message: message,
|
||||
message,
|
||||
icon: "export"
|
||||
};
|
||||
}
|
||||
@@ -152,7 +152,7 @@ ws.subscribeToMessages(async (message) => {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("export.export_in_progress", { progressCount: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("export.export_finished_successfully"));
|
||||
toast.closeAfter = 5000;
|
||||
toast.timeout = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
}
|
||||
|
||||
11
apps/client/src/widgets/dialogs/info.css
Normal file
11
apps/client/src/widgets/dialogs/info.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.modal.info-dialog {
|
||||
user-select: text;
|
||||
|
||||
h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import { EventData } from "../../components/app_context";
|
||||
import Modal from "../react/Modal";
|
||||
import Modal, { type ModalProps } from "../react/Modal";
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { RawHtmlBlock } from "../react/RawHtml";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { isValidElement } from "preact";
|
||||
import { ConfirmWithMessageOptions } from "./confirm";
|
||||
import "./info.css";
|
||||
import server from "../../services/server";
|
||||
import { ToMarkdownResponse } from "@triliumnext/commons";
|
||||
import { copyTextWithToast } from "../../services/clipboard_ext";
|
||||
|
||||
export interface InfoExtraProps extends Partial<Pick<ModalProps, "size" | "title">> {
|
||||
/** Adds a button in the footer that allows easily copying the content of the infobox to clipboard. */
|
||||
copyToClipboardButton?: boolean;
|
||||
}
|
||||
|
||||
export type InfoProps = ConfirmWithMessageOptions & InfoExtraProps;
|
||||
|
||||
export default function InfoDialog() {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [ opts, setOpts ] = useState<EventData<"showInfoDialog">>();
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const okButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -18,21 +32,42 @@ export default function InfoDialog() {
|
||||
|
||||
return (<Modal
|
||||
className="info-dialog"
|
||||
size="sm"
|
||||
title={t("info.modalTitle")}
|
||||
size={opts?.size ?? "sm"}
|
||||
title={opts?.title ?? t("info.modalTitle")}
|
||||
onHidden={() => {
|
||||
opts?.callback?.();
|
||||
setShown(false);
|
||||
}}
|
||||
onShown={() => okButtonRef.current?.focus?.()}
|
||||
footer={<Button
|
||||
buttonRef={okButtonRef}
|
||||
text={t("info.okButton")}
|
||||
onClick={() => setShown(false)}
|
||||
/>}
|
||||
modalRef={modalRef}
|
||||
footer={<>
|
||||
{opts?.copyToClipboardButton && (
|
||||
<Button
|
||||
text={t("info.copy_to_clipboard")}
|
||||
icon="bx bx-copy"
|
||||
onClick={async () => {
|
||||
const htmlContent = modalRef.current?.querySelector<HTMLDivElement>(".modal-body")?.innerHTML;
|
||||
if (!htmlContent) return;
|
||||
|
||||
const { markdownContent } = await server.post<ToMarkdownResponse>("other/to-markdown", { htmlContent });
|
||||
copyTextWithToast(markdownContent);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
buttonRef={okButtonRef}
|
||||
text={t("info.okButton")}
|
||||
onClick={() => setShown(false)}
|
||||
/>
|
||||
</>}
|
||||
show={shown}
|
||||
stackable
|
||||
scrollable
|
||||
>
|
||||
<RawHtmlBlock className="info-dialog-content" html={opts?.message ?? ""} />
|
||||
{isValidElement(opts?.message)
|
||||
? opts?.message
|
||||
: <RawHtmlBlock className="info-dialog-content" html={opts?.message} />
|
||||
}
|
||||
</Modal>);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,12 @@ import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
|
||||
import { RenderMarkdownResponse } from "@triliumnext/commons";
|
||||
|
||||
export interface MarkdownImportOpts {
|
||||
editorApi: CKEditorApi;
|
||||
}
|
||||
|
||||
interface RenderMarkdownResponse {
|
||||
htmlContent: string;
|
||||
}
|
||||
|
||||
export default function MarkdownImportDialog() {
|
||||
const markdownImportTextArea = useRef<HTMLTextAreaElement>(null);
|
||||
const editorApiRef = useRef<CKEditorApi>(null);
|
||||
|
||||
31
apps/client/src/widgets/launch_bar/BookmarkButtons.css
Normal file
31
apps/client/src/widgets/launch_bar/BookmarkButtons.css
Normal file
@@ -0,0 +1,31 @@
|
||||
.bookmark-folder-widget {
|
||||
min-width: 400px;
|
||||
max-height: 500px;
|
||||
padding: 7px 15px 0 15px;
|
||||
font-size: 1.2rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget ul {
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget .note-link {
|
||||
display: block;
|
||||
padding: 5px 10px 5px 5px;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget .note-link:hover {
|
||||
background-color: var(--accented-background-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dropdown-menu .bookmark-folder-widget a:hover:not(.disabled) {
|
||||
text-decoration: none;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.bookmark-folder-widget li .note-link {
|
||||
padding-inline-start: 35px;
|
||||
}
|
||||
59
apps/client/src/widgets/launch_bar/BookmarkButtons.tsx
Normal file
59
apps/client/src/widgets/launch_bar/BookmarkButtons.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useContext, useMemo } from "preact/hooks";
|
||||
import { LaunchBarContext, LaunchBarDropdownButton, useLauncherIconAndTitle } from "./launch_bar_widgets";
|
||||
import { CSSProperties } from "preact";
|
||||
import type FNote from "../../entities/fnote";
|
||||
import { useChildNotes, useNoteLabelBoolean } from "../react/hooks";
|
||||
import "./BookmarkButtons.css";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { CustomNoteLauncher } from "./GenericButtons";
|
||||
|
||||
const PARENT_NOTE_ID = "_lbBookmarks";
|
||||
|
||||
export default function BookmarkButtons() {
|
||||
const { isHorizontalLayout } = useContext(LaunchBarContext);
|
||||
const style = useMemo<CSSProperties>(() => ({
|
||||
display: "flex",
|
||||
flexDirection: isHorizontalLayout ? "row" : "column",
|
||||
contain: "none"
|
||||
}), [ isHorizontalLayout ]);
|
||||
const childNotes = useChildNotes(PARENT_NOTE_ID);
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
{childNotes?.map(childNote => <SingleBookmark key={childNote.noteId} note={childNote} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleBookmark({ note }: { note: FNote }) {
|
||||
const [ bookmarkFolder ] = useNoteLabelBoolean(note, "bookmarkFolder");
|
||||
return bookmarkFolder
|
||||
? <BookmarkFolder note={note} />
|
||||
: <CustomNoteLauncher launcherNote={note} getTargetNoteId={() => note.noteId} />
|
||||
}
|
||||
|
||||
function BookmarkFolder({ note }: { note: FNote }) {
|
||||
const { icon, title } = useLauncherIconAndTitle(note);
|
||||
const childNotes = useChildNotes(note.noteId);
|
||||
|
||||
return (
|
||||
<LaunchBarDropdownButton
|
||||
icon={icon}
|
||||
title={title}
|
||||
>
|
||||
<div className="bookmark-folder-widget">
|
||||
<div className="parent-note">
|
||||
<NoteLink notePath={note.noteId} noPreview showNoteIcon containerClassName="note-link" noTnLink />
|
||||
</div>
|
||||
|
||||
<ul className="children-notes">
|
||||
{childNotes.map(childNote => (
|
||||
<li key={childNote.noteId}>
|
||||
<NoteLink notePath={childNote.noteId} noPreview showNoteIcon containerClassName="note-link" noTnLink />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</LaunchBarDropdownButton>
|
||||
)
|
||||
}
|
||||
221
apps/client/src/widgets/launch_bar/Calendar.tsx
Normal file
221
apps/client/src/widgets/launch_bar/Calendar.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useTriliumOptionInt } from "../react/hooks";
|
||||
import clsx from "clsx";
|
||||
import server from "../../services/server";
|
||||
import { TargetedMouseEvent, VNode } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { Dayjs } from "@triliumnext/commons";
|
||||
import { t } from "../../services/i18n";
|
||||
|
||||
interface DateNotesForMonth {
|
||||
[date: string]: string;
|
||||
}
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
t("calendar.sun"),
|
||||
t("calendar.mon"),
|
||||
t("calendar.tue"),
|
||||
t("calendar.wed"),
|
||||
t("calendar.thu"),
|
||||
t("calendar.fri"),
|
||||
t("calendar.sat")
|
||||
];
|
||||
|
||||
interface DateRangeInfo {
|
||||
weekNumbers: number[];
|
||||
dates: Dayjs[];
|
||||
}
|
||||
|
||||
export interface CalendarArgs {
|
||||
date: Dayjs;
|
||||
todaysDate: Dayjs;
|
||||
activeDate: Dayjs | null;
|
||||
onDateClicked(date: string, e: TargetedMouseEvent<HTMLAnchorElement>): void;
|
||||
onWeekClicked?: (week: string, e: TargetedMouseEvent<HTMLAnchorElement>) => void;
|
||||
weekNotes: string[];
|
||||
}
|
||||
|
||||
export default function Calendar(args: CalendarArgs) {
|
||||
const [ rawFirstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek");
|
||||
const firstDayOfWeekISO = (rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek);
|
||||
|
||||
const date = args.date;
|
||||
const firstDay = date.startOf('month');
|
||||
const firstDayISO = firstDay.isoWeekday();
|
||||
const monthInfo = getMonthInformation(date, firstDayISO, firstDayOfWeekISO);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CalendarWeekHeader rawFirstDayOfWeek={rawFirstDayOfWeek} />
|
||||
<div className="calendar-body" data-calendar-area="month">
|
||||
{firstDayISO !== firstDayOfWeekISO && <PreviousMonthDays info={monthInfo.prevMonth} {...args} />}
|
||||
<CurrentMonthDays firstDayOfWeekISO={firstDayOfWeekISO} {...args} />
|
||||
<NextMonthDays dates={monthInfo.nextMonth.dates} {...args} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarWeekHeader({ rawFirstDayOfWeek }: { rawFirstDayOfWeek: number }) {
|
||||
let localeDaysOfWeek = [...DAYS_OF_WEEK];
|
||||
const shifted = localeDaysOfWeek.splice(0, rawFirstDayOfWeek);
|
||||
localeDaysOfWeek = ['', ...localeDaysOfWeek, ...shifted];
|
||||
|
||||
return (
|
||||
<div className="calendar-week">
|
||||
{localeDaysOfWeek.map(dayOfWeek => <span key={dayOfWeek}>{dayOfWeek}</span>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviousMonthDays({ date, info: { dates, weekNumbers }, ...args }: { date: Dayjs, info: DateRangeInfo } & CalendarArgs) {
|
||||
const prevMonth = date.subtract(1, 'month').format('YYYY-MM');
|
||||
const [ dateNotesForPrevMonth, setDateNotesForPrevMonth ] = useState<DateNotesForMonth>();
|
||||
|
||||
useEffect(() => {
|
||||
server.get<DateNotesForMonth>(`special-notes/notes-for-month/${prevMonth}`).then(setDateNotesForPrevMonth);
|
||||
}, [ date ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CalendarWeek date={date} weekNumber={weekNumbers[0]} {...args} />
|
||||
{dates.map(date => <CalendarDay key={date.toISOString()} date={date} dateNotesForMonth={dateNotesForPrevMonth} className="calendar-date-prev-month" {...args} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentMonthDays({ date, firstDayOfWeekISO, ...args }: { date: Dayjs, firstDayOfWeekISO: number } & CalendarArgs) {
|
||||
let dateCursor = date;
|
||||
const currentMonth = date.month();
|
||||
const items: VNode[] = [];
|
||||
const curMonthString = date.format('YYYY-MM');
|
||||
const [ dateNotesForCurMonth, setDateNotesForCurMonth ] = useState<DateNotesForMonth>();
|
||||
|
||||
useEffect(() => {
|
||||
server.get<DateNotesForMonth>(`special-notes/notes-for-month/${curMonthString}`).then(setDateNotesForCurMonth);
|
||||
}, [ date ]);
|
||||
|
||||
while (dateCursor.month() === currentMonth) {
|
||||
const weekNumber = getWeekNumber(dateCursor, firstDayOfWeekISO);
|
||||
if (dateCursor.isoWeekday() === firstDayOfWeekISO) {
|
||||
items.push(<CalendarWeek key={`${dateCursor.year()}-W${weekNumber}`} date={dateCursor} weekNumber={weekNumber} {...args}/>)
|
||||
}
|
||||
|
||||
items.push(<CalendarDay key={dateCursor.toISOString()} date={dateCursor} dateNotesForMonth={dateNotesForCurMonth} {...args} />)
|
||||
dateCursor = dateCursor.add(1, "day");
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function NextMonthDays({ date, dates, ...args }: { date: Dayjs, dates: Dayjs[] } & CalendarArgs) {
|
||||
const nextMonth = date.add(1, 'month').format('YYYY-MM');
|
||||
const [ dateNotesForNextMonth, setDateNotesForNextMonth ] = useState<DateNotesForMonth>();
|
||||
|
||||
useEffect(() => {
|
||||
server.get<DateNotesForMonth>(`special-notes/notes-for-month/${nextMonth}`).then(setDateNotesForNextMonth);
|
||||
}, [ date ]);
|
||||
|
||||
return dates.map(date => (
|
||||
<CalendarDay key={date.toISOString()} date={date} dateNotesForMonth={dateNotesForNextMonth} className="calendar-date-next-month" {...args} />
|
||||
));
|
||||
}
|
||||
|
||||
function CalendarDay({ date, dateNotesForMonth, className, activeDate, todaysDate, onDateClicked }: { date: Dayjs, dateNotesForMonth?: DateNotesForMonth, className?: string } & CalendarArgs) {
|
||||
const dateString = date.local().format('YYYY-MM-DD');
|
||||
const dateNoteId = dateNotesForMonth?.[dateString];
|
||||
return (
|
||||
<a
|
||||
className={clsx("calendar-date", className,
|
||||
dateNoteId && "calendar-date-exists",
|
||||
date.isSame(activeDate, "day") && "calendar-date-active",
|
||||
date.isSame(todaysDate, "day") && "calendar-date-today"
|
||||
)}
|
||||
data-calendar-date={date.local().format("YYYY-MM-DD")}
|
||||
data-href={dateNoteId && `#root/${dateNoteId}`}
|
||||
onClick={(e) => onDateClicked(dateString, e)}
|
||||
>
|
||||
<span>
|
||||
{date.date()}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumber: number, weekNotes: string[] } & Pick<CalendarArgs, "date" | "onWeekClicked">) {
|
||||
const localDate = date.local();
|
||||
|
||||
// Handle case where week is in between years.
|
||||
let year = localDate.year();
|
||||
if (localDate.month() === 11 && weekNumber === 1) year++;
|
||||
|
||||
const weekString = `${year}-W${String(weekNumber).padStart(2, '0')}`;
|
||||
|
||||
if (onWeekClicked) {
|
||||
return (
|
||||
<a
|
||||
className={clsx("calendar-week-number", "calendar-date",
|
||||
weekNotes.includes(weekString) && "calendar-date-exists")}
|
||||
data-calendar-week-number={weekNumber}
|
||||
data-date={date.local().format("YYYY-MM-DD")}
|
||||
onClick={(e) => onWeekClicked(weekString, e)}
|
||||
>{weekNumber}</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="calendar-week-number calendar-week-number-disabled"
|
||||
data-calendar-week-number={weekNumber}
|
||||
>{weekNumber}</span>);
|
||||
}
|
||||
|
||||
export function getMonthInformation(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number) {
|
||||
return {
|
||||
prevMonth: getPrevMonthDays(date, firstDayISO, firstDayOfWeekISO),
|
||||
nextMonth: getNextMonthDays(date, firstDayOfWeekISO)
|
||||
}
|
||||
}
|
||||
|
||||
function getPrevMonthDays(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number): DateRangeInfo {
|
||||
const prevMonthLastDay = date.subtract(1, 'month').endOf('month');
|
||||
const daysToAdd = (firstDayISO - firstDayOfWeekISO + 7) % 7;
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
const firstDay = date.startOf('month');
|
||||
const weekNumber = getWeekNumber(firstDay, firstDayOfWeekISO);
|
||||
|
||||
// Get dates from previous month
|
||||
for (let i = daysToAdd - 1; i >= 0; i--) {
|
||||
dates.push(prevMonthLastDay.subtract(i, 'day'));
|
||||
}
|
||||
|
||||
return { weekNumbers: [ weekNumber ], dates };
|
||||
}
|
||||
|
||||
function getNextMonthDays(date: Dayjs, firstDayOfWeekISO: number): DateRangeInfo {
|
||||
const lastDayOfMonth = date.endOf('month');
|
||||
const lastDayISO = lastDayOfMonth.isoWeekday();
|
||||
const lastDayOfUserWeek = ((firstDayOfWeekISO + 6 - 1) % 7) + 1;
|
||||
const nextMonthFirstDay = date.add(1, 'month').startOf('month');
|
||||
const dates: Dayjs[] = [];
|
||||
|
||||
if (lastDayISO !== lastDayOfUserWeek) {
|
||||
const daysToAdd = (lastDayOfUserWeek - lastDayISO + 7) % 7;
|
||||
|
||||
for (let i = 0; i < daysToAdd; i++) {
|
||||
dates.push(nextMonthFirstDay.add(i, 'day'));
|
||||
}
|
||||
}
|
||||
return { weekNumbers: [], dates };
|
||||
}
|
||||
|
||||
export function getWeekNumber(date: Dayjs, firstDayOfWeekISO: number): number {
|
||||
const weekStart = getWeekStartDate(date, firstDayOfWeekISO);
|
||||
return weekStart.isoWeek();
|
||||
}
|
||||
|
||||
function getWeekStartDate(date: Dayjs, firstDayOfWeekISO: number): Dayjs {
|
||||
const currentISO = date.isoWeekday();
|
||||
const diff = (currentISO - firstDayOfWeekISO + 7) % 7;
|
||||
return date.clone().subtract(diff, "day").startOf("day");
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
.calendar-dropdown-widget {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -174,4 +173,13 @@
|
||||
background-color: var(--hover-item-background-color);
|
||||
color: var(--hover-item-text-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .form-control {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-month-selector .dropdown-menu {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
188
apps/client/src/widgets/launch_bar/CalendarWidget.tsx
Normal file
188
apps/client/src/widgets/launch_bar/CalendarWidget.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Dispatch, StateUpdater, useMemo, useRef, useState } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { LaunchBarDropdownButton, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets";
|
||||
import { Dayjs, dayjs } from "@triliumnext/commons";
|
||||
import appContext from "../../components/app_context";
|
||||
import "./CalendarWidget.css";
|
||||
import Calendar, { CalendarArgs } from "./Calendar";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { t } from "../../services/i18n";
|
||||
import FormDropdownList from "../react/FormDropdownList";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import toast from "../../services/toast";
|
||||
import date_notes from "../../services/date_notes";
|
||||
import { Dropdown } from "bootstrap";
|
||||
import search from "../../services/search";
|
||||
import server from "../../services/server";
|
||||
|
||||
const MONTHS = [
|
||||
t("calendar.january"),
|
||||
t("calendar.february"),
|
||||
t("calendar.march"),
|
||||
t("calendar.april"),
|
||||
t("calendar.may"),
|
||||
t("calendar.june"),
|
||||
t("calendar.july"),
|
||||
t("calendar.august"),
|
||||
t("calendar.september"),
|
||||
t("calendar.october"),
|
||||
t("calendar.november"),
|
||||
t("calendar.december")
|
||||
];
|
||||
|
||||
export default function CalendarWidget({ launcherNote }: LauncherNoteProps) {
|
||||
const { title, icon } = useLauncherIconAndTitle(launcherNote);
|
||||
const [ calendarArgs, setCalendarArgs ] = useState<Pick<CalendarArgs, "activeDate" | "todaysDate">>();
|
||||
const [ date, setDate ] = useState<Dayjs>();
|
||||
const dropdownRef = useRef<Dropdown>(null);
|
||||
const [ enableWeekNotes, setEnableWeekNotes ] = useState(false);
|
||||
const [ weekNotes, setWeekNotes ] = useState<string[]>([]);
|
||||
const calendarRootRef = useRef<FNote>();
|
||||
|
||||
async function checkEnableWeekNotes() {
|
||||
if (!calendarRootRef.current) {
|
||||
const notes = await search.searchForNotes("#calendarRoot");
|
||||
if (!notes.length) return;
|
||||
calendarRootRef.current = notes[0];
|
||||
}
|
||||
|
||||
if (!calendarRootRef.current) return;
|
||||
|
||||
const enableWeekNotes = calendarRootRef.current.hasLabel("enableWeekNote");
|
||||
setEnableWeekNotes(enableWeekNotes);
|
||||
|
||||
if (enableWeekNotes) {
|
||||
server.get<string[]>(`attribute-values/weekNote`).then(setWeekNotes);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LaunchBarDropdownButton
|
||||
icon={icon} title={title}
|
||||
onShown={async () => {
|
||||
const dateNote = appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote");
|
||||
const activeDate = dateNote ? dayjs(`${dateNote}T12:00:00`) : null
|
||||
const todaysDate = dayjs();
|
||||
setCalendarArgs({
|
||||
activeDate,
|
||||
todaysDate,
|
||||
});
|
||||
setDate(dayjs(activeDate || todaysDate).startOf('month'));
|
||||
try {
|
||||
await checkEnableWeekNotes();
|
||||
} catch (e: unknown) {
|
||||
// Non-critical.
|
||||
}
|
||||
}}
|
||||
dropdownRef={dropdownRef}
|
||||
dropdownOptions={{
|
||||
autoClose: "outside"
|
||||
}}
|
||||
>
|
||||
{calendarArgs && date && <div className="calendar-dropdown-widget" style={{ width: 400 }}>
|
||||
<CalendarHeader date={date} setDate={setDate} />
|
||||
<Calendar
|
||||
date={date}
|
||||
onDateClicked={async (date, e) => {
|
||||
const note = await date_notes.getDayNote(date);
|
||||
if (note) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
|
||||
dropdownRef.current?.hide();
|
||||
} else {
|
||||
toast.showError(t("calendar.cannot_find_day_note"));
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onWeekClicked={enableWeekNotes ? async (week, e) => {
|
||||
const note = await date_notes.getWeekNote(week);
|
||||
if (note) {
|
||||
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
|
||||
dropdownRef.current?.hide();
|
||||
} else {
|
||||
toast.showError(t("calendar.cannot_find_week_note"));
|
||||
}
|
||||
e.stopPropagation();
|
||||
} : undefined}
|
||||
weekNotes={weekNotes}
|
||||
{...calendarArgs}
|
||||
/>
|
||||
</div>}
|
||||
</LaunchBarDropdownButton>
|
||||
)
|
||||
}
|
||||
|
||||
interface CalendarHeaderProps {
|
||||
date: Dayjs;
|
||||
setDate: Dispatch<StateUpdater<Dayjs | undefined>>;
|
||||
}
|
||||
|
||||
function CalendarHeader(props: CalendarHeaderProps) {
|
||||
return (
|
||||
<div className="calendar-header">
|
||||
<CalendarMonthSelector {...props} />
|
||||
<CalendarYearSelector {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarMonthSelector({ date, setDate }: CalendarHeaderProps) {
|
||||
const months = useMemo(() => (
|
||||
Array.from(MONTHS.entries().map(([ index, text ]) => ({
|
||||
index: index.toString(), text
|
||||
})))
|
||||
), []);
|
||||
|
||||
return (
|
||||
<div className="calendar-month-selector">
|
||||
<AdjustDateButton date={date} setDate={setDate} direction="prev" unit="month" />
|
||||
<FormDropdownList
|
||||
values={months} currentValue={date.month().toString()}
|
||||
keyProperty="index" titleProperty="text"
|
||||
onChange={(index) => setDate(date.set("month", parseInt(index, 10)))}
|
||||
buttonProps={{ "data-calendar-input": "month" }}
|
||||
dropdownOptions={{ display: "static" }}
|
||||
/>
|
||||
<AdjustDateButton date={date} setDate={setDate} direction="next" unit="month" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarYearSelector({ date, setDate }: CalendarHeaderProps) {
|
||||
return (
|
||||
<div className="calendar-year-selector">
|
||||
<AdjustDateButton date={date} setDate={setDate} direction="prev" unit="year" />
|
||||
<FormTextBox
|
||||
type="number"
|
||||
min="1900" max="2999" step="1"
|
||||
currentValue={date.year().toString()}
|
||||
onChange={(newValue) => {
|
||||
const year = parseInt(newValue, 10);
|
||||
if (!Number.isNaN(year)) {
|
||||
setDate(date.set("year", year));
|
||||
}
|
||||
}}
|
||||
data-calendar-input="year"
|
||||
/>
|
||||
<AdjustDateButton date={date} setDate={setDate} direction="next" unit="year" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdjustDateButton({ date, setDate, unit, direction }: CalendarHeaderProps & {
|
||||
direction: "prev" | "next",
|
||||
unit: "month" | "year"
|
||||
}) {
|
||||
return (
|
||||
<ActionButton
|
||||
icon={direction === "prev" ? "bx bx-chevron-left" : "bx bx-chevron-right" }
|
||||
className="calendar-btn tn-tool-button"
|
||||
noIconActionClass
|
||||
text=""
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newDate = direction === "prev" ? date.subtract(1, unit) : date.add(1, unit);
|
||||
setDate(newDate);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
55
apps/client/src/widgets/launch_bar/GenericButtons.tsx
Normal file
55
apps/client/src/widgets/launch_bar/GenericButtons.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from "preact/hooks";
|
||||
import appContext from "../../components/app_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import link_context_menu from "../../menus/link_context_menu";
|
||||
import { escapeHtml, isCtrlKey } from "../../services/utils";
|
||||
import { useGlobalShortcut, useNoteLabel } from "../react/hooks";
|
||||
import { LaunchBarActionButton, useLauncherIconAndTitle } from "./launch_bar_widgets";
|
||||
|
||||
export function CustomNoteLauncher({ launcherNote, getTargetNoteId, getHoistedNoteId }: {
|
||||
launcherNote: FNote;
|
||||
getTargetNoteId: (launcherNote: FNote) => string | null | Promise<string | null>;
|
||||
getHoistedNoteId?: (launcherNote: FNote) => string | null;
|
||||
keyboardShortcut?: string;
|
||||
}) {
|
||||
const { icon, title } = useLauncherIconAndTitle(launcherNote);
|
||||
|
||||
const launch = useCallback(async (evt: MouseEvent | KeyboardEvent) => {
|
||||
if (evt.which === 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNoteId = await getTargetNoteId(launcherNote);
|
||||
if (!targetNoteId) return;
|
||||
|
||||
const hoistedNoteIdWithDefault = getHoistedNoteId?.(launcherNote) || appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
const ctrlKey = isCtrlKey(evt);
|
||||
|
||||
if ((evt.which === 1 && ctrlKey) || evt.which === 2) {
|
||||
const activate = evt.shiftKey ? true : false;
|
||||
await appContext.tabManager.openInNewTab(targetNoteId, hoistedNoteIdWithDefault, activate);
|
||||
} else {
|
||||
await appContext.tabManager.openInSameTab(targetNoteId);
|
||||
}
|
||||
}, [ launcherNote, getTargetNoteId, getHoistedNoteId ]);
|
||||
|
||||
// Keyboard shortcut.
|
||||
const [ shortcut ] = useNoteLabel(launcherNote, "keyboardShortcut");
|
||||
useGlobalShortcut(shortcut, launch);
|
||||
|
||||
return (
|
||||
<LaunchBarActionButton
|
||||
icon={icon}
|
||||
text={escapeHtml(title)}
|
||||
onClick={launch}
|
||||
onAuxClick={launch}
|
||||
onContextMenu={async evt => {
|
||||
evt.preventDefault();
|
||||
const targetNoteId = await getTargetNoteId(launcherNote);
|
||||
if (targetNoteId) {
|
||||
link_context_menu.openContextMenu(targetNoteId, evt);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
86
apps/client/src/widgets/launch_bar/HistoryNavigation.tsx
Normal file
86
apps/client/src/widgets/launch_bar/HistoryNavigation.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { dynamicRequire, isElectron } from "../../services/utils";
|
||||
import { LaunchBarActionButton, useLauncherIconAndTitle } from "./launch_bar_widgets";
|
||||
import type { WebContents } from "electron";
|
||||
import contextMenu, { MenuCommandItem } from "../../menus/context_menu";
|
||||
import tree from "../../services/tree";
|
||||
import link from "../../services/link";
|
||||
|
||||
interface HistoryNavigationProps {
|
||||
launcherNote: FNote;
|
||||
command: "backInNoteHistory" | "forwardInNoteHistory";
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
export default function HistoryNavigationButton({ launcherNote, command }: HistoryNavigationProps) {
|
||||
const { icon, title } = useLauncherIconAndTitle(launcherNote);
|
||||
const webContentsRef = useRef<WebContents>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const webContents = dynamicRequire("@electron/remote").getCurrentWebContents();
|
||||
// without this, the history is preserved across frontend reloads
|
||||
webContents?.clearHistory();
|
||||
webContentsRef.current = webContents;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LaunchBarActionButton
|
||||
icon={icon}
|
||||
text={title}
|
||||
triggerCommand={command}
|
||||
onContextMenu={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const webContents = webContentsRef.current;
|
||||
if (!webContents || webContents.navigationHistory.length() < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
let items: MenuCommandItem<string>[] = [];
|
||||
|
||||
const history = webContents.navigationHistory.getAllEntries();
|
||||
const activeIndex = webContents.navigationHistory.getActiveIndex();
|
||||
|
||||
for (const idx in history) {
|
||||
const { notePath } = link.parseNavigationStateFromUrl(history[idx].url);
|
||||
if (!notePath) continue;
|
||||
|
||||
const title = await tree.getNotePathTitle(notePath);
|
||||
|
||||
items.push({
|
||||
title,
|
||||
command: idx,
|
||||
uiIcon:
|
||||
parseInt(idx) === activeIndex
|
||||
? "bx bx-radio-circle-marked" // compare with type coercion!
|
||||
: parseInt(idx) < activeIndex
|
||||
? "bx bx-left-arrow-alt"
|
||||
: "bx bx-right-arrow-alt"
|
||||
});
|
||||
}
|
||||
|
||||
items.reverse();
|
||||
|
||||
if (items.length > HISTORY_LIMIT) {
|
||||
items = items.slice(0, HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
contextMenu.show({
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items,
|
||||
selectMenuItemHandler: (item: MenuCommandItem<string>) => {
|
||||
if (item && item.command && webContents) {
|
||||
const idx = parseInt(item.command, 10);
|
||||
webContents.navigationHistory.goToIndex(idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user