mirror of
https://github.com/zadam/trilium.git
synced 2025-10-26 15:56:29 +01:00
Compare commits
308 Commits
feat/bette
...
feat/ui-op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35dbca18b | ||
|
|
7468d6147a | ||
|
|
7c78d749de | ||
|
|
85dd99a3c4 | ||
|
|
0a9c0234e2 | ||
|
|
3e3cc8c541 | ||
|
|
d1538508e8 | ||
|
|
9b1da8c311 | ||
|
|
e4a8258acf | ||
|
|
5e88043c7b | ||
|
|
bedf9112fb | ||
|
|
03681d23c5 | ||
|
|
4c8da70ef3 | ||
|
|
ed5da5cd4a | ||
|
|
dc5fccdbcd | ||
|
|
91aea333c7 | ||
|
|
a0de01cff1 | ||
|
|
a41ed34193 | ||
|
|
49e8811c18 | ||
|
|
488563a82e | ||
|
|
a1b18c7f97 | ||
|
|
9958a6e1bf | ||
|
|
1fc6d8aca7 | ||
|
|
3e9ec2d943 | ||
|
|
1420def1c3 | ||
|
|
3b4184e765 | ||
|
|
b70e25d348 | ||
|
|
772c0bbe1a | ||
|
|
144021c053 | ||
|
|
8abd3ed3f1 | ||
|
|
53ed510c92 | ||
|
|
4ec46a2ebd | ||
|
|
db6f948499 | ||
|
|
05c73011f5 | ||
|
|
3b733d01f1 | ||
|
|
ebf21296d4 | ||
|
|
6f83ac4822 | ||
|
|
d358924324 | ||
|
|
f9a3606ca2 | ||
|
|
33299ad51e | ||
|
|
8752182e7e | ||
|
|
0551ac8ead | ||
|
|
6d5a11bd4d | ||
|
|
ce19d84247 | ||
|
|
f24aa45a3b | ||
|
|
64a28a7e75 | ||
|
|
249a755312 | ||
|
|
a3d51a013c | ||
|
|
839def9959 | ||
|
|
fd432a7100 | ||
|
|
60a07ce1e7 | ||
|
|
88c5700d87 | ||
|
|
d59993abf6 | ||
|
|
0754011909 | ||
|
|
376bb66cab | ||
|
|
588e15c633 | ||
|
|
93b8ad20d7 | ||
|
|
e51b3d760d | ||
|
|
91f3bc4488 | ||
|
|
3e80a99bbf | ||
|
|
37cdb55e79 | ||
|
|
58b66c0c95 | ||
|
|
e5f9db86a1 | ||
|
|
f138f99356 | ||
|
|
c42f4b9814 | ||
|
|
0a9fb886e3 | ||
|
|
3c4577201f | ||
|
|
816421188f | ||
|
|
5b15d2c4c6 | ||
|
|
4bc7165452 | ||
|
|
82d6531e8c | ||
|
|
d6209035c3 | ||
|
|
1d7799f981 | ||
|
|
51291a61e6 | ||
|
|
0841603be0 | ||
|
|
59ba6a0b1e | ||
|
|
53eda46043 | ||
|
|
cbc9fb7d08 | ||
|
|
1f479b20be | ||
|
|
f00b8e9522 | ||
|
|
c7dd271516 | ||
|
|
a947a61d65 | ||
|
|
0122f1cc5e | ||
|
|
acb905a3e6 | ||
|
|
7422eb5598 | ||
|
|
e721166f95 | ||
|
|
5a48130fa4 | ||
|
|
b60fe1ad10 | ||
|
|
1405b0147c | ||
|
|
222a7a57bc | ||
|
|
cddf9f0242 | ||
|
|
3e17ff5e7b | ||
|
|
04973094f2 | ||
|
|
018a6cb84a | ||
|
|
44825af0c0 | ||
|
|
cfb3607052 | ||
|
|
c5ec928aac | ||
|
|
8d0183a9fb | ||
|
|
ecd4079871 | ||
|
|
3ed975f2e6 | ||
|
|
c6deb537d5 | ||
|
|
e7b3d806a7 | ||
|
|
d1a0778b48 | ||
|
|
378634567f | ||
|
|
ed56ed2be0 | ||
|
|
648aa7e3b0 | ||
|
|
73ff41f2b2 | ||
|
|
3837466cb3 | ||
|
|
b97a5ef888 | ||
|
|
2ff1276ebb | ||
|
|
227cf5de85 | ||
|
|
ccf52be431 | ||
|
|
07713e988c | ||
|
|
f934318625 | ||
|
|
6fb90abd75 | ||
|
|
27cc33888a | ||
|
|
95af901808 | ||
|
|
c5a7f84250 | ||
|
|
a71d28500d | ||
|
|
436fd16f3a | ||
|
|
ca34bf42f6 | ||
|
|
fbf2315f57 | ||
|
|
72f50dcb6b | ||
|
|
fd4c2f79a7 | ||
|
|
72f9335213 | ||
|
|
53d97047a3 | ||
|
|
2ba3666e23 | ||
|
|
4a1d379ab4 | ||
|
|
73167e1e30 | ||
|
|
ffc13f5de3 | ||
|
|
9ba23d49d8 | ||
|
|
222a6c48a7 | ||
|
|
e33208e6ec | ||
|
|
af8781eaa7 | ||
|
|
167b1a8d2e | ||
|
|
0a7aff507c | ||
|
|
103532aad9 | ||
|
|
16939e9fd5 | ||
|
|
4ef6169041 | ||
|
|
9ebee42118 | ||
|
|
234d3997b1 | ||
|
|
3ba0bcea4e | ||
|
|
701855344e | ||
|
|
71b627fbc7 | ||
|
|
5a4fc2c690 | ||
|
|
0d67db52a2 | ||
|
|
d971554201 | ||
|
|
8fd7d7176e | ||
|
|
675575eed9 | ||
|
|
2122cde293 | ||
|
|
b68a554bba | ||
|
|
33043c7133 | ||
|
|
2e0f606a7a | ||
|
|
87878dd6a7 | ||
|
|
5296e073cc | ||
|
|
7bfb7d6f6e | ||
|
|
b5069cc7c2 | ||
|
|
3b6791f51a | ||
|
|
0b0be77e02 | ||
|
|
60db10559e | ||
|
|
76b066ba4a | ||
|
|
a28db32369 | ||
|
|
2523632391 | ||
|
|
53548c356a | ||
|
|
565904ff5d | ||
|
|
e0c5545f8c | ||
|
|
bc21285289 | ||
|
|
bbf8d757cd | ||
|
|
318d504fad | ||
|
|
fd5038148c | ||
|
|
693ca9291e | ||
|
|
cfd8afc226 | ||
|
|
3e52ca7600 | ||
|
|
482522e802 | ||
|
|
8b5b6a01c6 | ||
|
|
5614891d92 | ||
|
|
b9b4961f3c | ||
|
|
7b83b20339 | ||
|
|
e4403dd316 | ||
|
|
3f267fe6c9 | ||
|
|
3229471485 | ||
|
|
62bac1adf9 | ||
|
|
82becfd52a | ||
|
|
92f035545b | ||
|
|
74d8ea7dcb | ||
|
|
ac3f087279 | ||
|
|
1cc4eb98c1 | ||
|
|
e99bdf8f24 | ||
|
|
b4f521a141 | ||
|
|
1e23bc09f1 | ||
|
|
e3ec90405d | ||
|
|
41c87794a4 | ||
|
|
e62d2d4fda | ||
|
|
93adaa0f52 | ||
|
|
263a5d2067 | ||
|
|
f0a5005794 | ||
|
|
577457c8ab | ||
|
|
c0c450c444 | ||
|
|
1e1e0b0f51 | ||
|
|
a19204a1d5 | ||
|
|
1d139bfdfe | ||
|
|
75072decec | ||
|
|
0cf2ad6901 | ||
|
|
ccbd57a0c0 | ||
|
|
92e6c8c445 | ||
|
|
1e966f1d59 | ||
|
|
6872c2194e | ||
|
|
5b6a0216db | ||
|
|
e9a7194cd6 | ||
|
|
26898b9122 | ||
|
|
3e00e490cf | ||
|
|
c02ed17ebc | ||
|
|
fb559d66fe | ||
|
|
25dce64c3b | ||
|
|
6f19fde76e | ||
|
|
33ae91f49c | ||
|
|
99c179e29a | ||
|
|
1dbcb5a027 | ||
|
|
54d613e00e | ||
|
|
1f8aa90482 | ||
|
|
c9dcbef014 | ||
|
|
68086ec3f1 | ||
|
|
f62078d02b | ||
|
|
ab1d8594ea | ||
|
|
c368ec3c38 | ||
|
|
1a15782686 | ||
|
|
3bd0aeef77 | ||
|
|
b463baedd2 | ||
|
|
ae77c41dab | ||
|
|
807d909acd | ||
|
|
fa4f5f526e | ||
|
|
edff43cdb3 | ||
|
|
46fe45528c | ||
|
|
b4b53da6a4 | ||
|
|
41fd270080 | ||
|
|
410bb3cdca | ||
|
|
bc6fc24fbd | ||
|
|
c039f06c2b | ||
|
|
520effbbb7 | ||
|
|
a42d780724 | ||
|
|
da92255dd6 | ||
|
|
cce3d3bce8 | ||
|
|
f524e99290 | ||
|
|
ba19fc7cf3 | ||
|
|
22c3de582f | ||
|
|
48896e67cb | ||
|
|
16cd91eb02 | ||
|
|
7e03774b8e | ||
|
|
a04f6e3858 | ||
|
|
96eb1be556 | ||
|
|
f8e20a1405 | ||
|
|
c67c3a6861 | ||
|
|
d04897e011 | ||
|
|
558ae1a2ea | ||
|
|
64bffb82b1 | ||
|
|
81ac390eab | ||
|
|
0db556fac2 | ||
|
|
2793df06c4 | ||
|
|
e7b448e2bc | ||
|
|
d2bc72d54f | ||
|
|
83b22b4861 | ||
|
|
d42a949602 | ||
|
|
83e1512b59 | ||
|
|
ba6a1ec584 | ||
|
|
6685e583f2 | ||
|
|
d6032c912e | ||
|
|
25527ecc21 | ||
|
|
e0e7bd42cc | ||
|
|
fbc1af56ed | ||
|
|
8ff108db9e | ||
|
|
1dfcf960d3 | ||
|
|
9bdc51a3fb | ||
|
|
dbf3bcfacf | ||
|
|
3d5b269315 | ||
|
|
48f97da9cc | ||
|
|
9c954fbd81 | ||
|
|
c6bd41654f | ||
|
|
d65a74bb23 | ||
|
|
ff08bca042 | ||
|
|
a5d3d2e3b4 | ||
|
|
496a0667ee | ||
|
|
9be688b667 | ||
|
|
f3d9008c61 | ||
|
|
649a43c978 | ||
|
|
50568704ca | ||
|
|
b66b4dec83 | ||
|
|
8d0e807435 | ||
|
|
bf05ed7caf | ||
|
|
b5080eff00 | ||
|
|
c474769dd6 | ||
|
|
a6ae01da0b | ||
|
|
2bf4c44dbf | ||
|
|
5ca0fbba13 | ||
|
|
4cd84b2019 | ||
|
|
c502a45cf5 | ||
|
|
9e66914306 | ||
|
|
d33d27ee82 | ||
|
|
e2b13573ae | ||
|
|
ec74f5f1de | ||
|
|
5dee56debc | ||
|
|
5623fc992d | ||
|
|
1d28bfc570 | ||
|
|
084327e973 | ||
|
|
b2885efdc1 | ||
|
|
b65a75f138 | ||
|
|
0ee7f50bb4 | ||
|
|
02ce21bc18 | ||
|
|
3ba487bb00 |
22
.github/actions/build-electron/action.yml
vendored
22
.github/actions/build-electron/action.yml
vendored
@@ -162,3 +162,25 @@ runs:
|
||||
echo "Found ZIP: $zip_file"
|
||||
echo "Note: ZIP files are not code signed, but their contents should be"
|
||||
fi
|
||||
|
||||
- name: Sign the RPM
|
||||
if: inputs.os == 'linux'
|
||||
shell: ${{ inputs.shell }}
|
||||
run: |
|
||||
echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --import
|
||||
|
||||
# Import the key into RPM for verification
|
||||
gpg --export -a > pubkey
|
||||
rpm --import pubkey
|
||||
rm pubkey
|
||||
|
||||
# Sign the RPM
|
||||
rpm_file=$(find ./apps/desktop/upload -name "*.rpm" -print -quit)
|
||||
rpmsign --define "_gpg_name Trilium Notes Signing Key <triliumnotes@outlook.com>" --addsign "$rpm_file"
|
||||
rpm -Kv "$rpm_file"
|
||||
|
||||
# Validate code signing
|
||||
if ! rpm -K "$rpm_file" | grep -q "digests signatures OK"; then
|
||||
echo .rpm file not signed
|
||||
exit 1
|
||||
fi
|
||||
|
||||
1
.github/workflows/checks.yml
vendored
1
.github/workflows/checks.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check if PRs have conflicts
|
||||
uses: eps1lon/actions-label-merge-conflict@v3
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
with:
|
||||
dirtyLabel: "merge-conflicts"
|
||||
repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"
|
||||
|
||||
5
.github/workflows/nightly.yml
vendored
5
.github/workflows/nightly.yml
vendored
@@ -27,7 +27,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
nightly-electron:
|
||||
if: github.repository == 'TriliumNext/Trilium'
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
name: Deploy nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -76,6 +76,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
@@ -97,7 +98,7 @@ jobs:
|
||||
path: apps/desktop/upload
|
||||
|
||||
nightly-server:
|
||||
if: github.repository == 'TriliumNext/Trilium'
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
name: Deploy server nightly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -58,6 +58,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||
|
||||
- name: Upload the artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
34
README.md
34
README.md
@@ -1,11 +1,11 @@
|
||||
# Trilium Notes
|
||||
|
||||
 
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
[English](./README.md) | [Chinese](./docs/README-ZH_CN.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md)
|
||||
[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)
|
||||
|
||||
Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
|
||||
|
||||
@@ -46,15 +46,15 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
|
||||
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more.
|
||||
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
|
||||
|
||||
## ⚠️ Why TriliumNext?
|
||||
## ❓Why TriliumNext?
|
||||
|
||||
[The original Trilium project is in maintenance mode](https://github.com/zadam/trilium/issues/4620).
|
||||
The original Trilium developer ([Zadam](https://github.com/zadam)) has graciously given the Trilium repository to the community project which resides at https://github.com/TriliumNext
|
||||
|
||||
### Migrating from Trilium?
|
||||
### ⬆️Migrating from Zadam/Trilium?
|
||||
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Notes instance. Simply [install TriliumNext/Notes](#-installation) as usual and it will use your existing database.
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Trilium instance. Simply [install TriliumNext/Trilium](#-installation) as usual and it will use your existing database.
|
||||
|
||||
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext have their sync versions incremented.
|
||||
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
@@ -75,8 +75,8 @@ Feel free to join our official conversations. We would love to hear what feature
|
||||
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous discussions.)
|
||||
- The `General` Matrix room is also bridged to [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Notes/discussions) (For asynchronous discussions.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Notes/issues) (For bug reports and feature requests.)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For asynchronous discussions.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug reports and feature requests.)
|
||||
|
||||
## 🏗 Installation
|
||||
|
||||
@@ -104,13 +104,15 @@ Currently only the latest versions of Chrome & Firefox are supported (and tested
|
||||
|
||||
To use TriliumNext on a mobile device, you can use a mobile web browser to access the mobile interface of a server installation (see below).
|
||||
|
||||
If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid). Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
|
||||
See issue https://github.com/TriliumNext/Trilium/issues/4962 for more information on mobile app support.
|
||||
|
||||
See issue https://github.com/TriliumNext/Notes/issues/72 for more information on mobile app support.
|
||||
If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
|
||||
Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
|
||||
Note: It is best to disable automatic updates on your server installation (see below) when using TriliumDroid since the sync version must match between Trilium and TriliumDroid.
|
||||
|
||||
### Server
|
||||
|
||||
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/notes)) 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://triliumnext.github.io/Docs/Wiki/server-installation).
|
||||
|
||||
|
||||
## 💻 Contribute
|
||||
@@ -152,11 +154,11 @@ pnpm install
|
||||
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
|
||||
```
|
||||
|
||||
For more details, see the [development docs](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Building%20and%20deployment/Running%20a%20development%20build.md).
|
||||
For more details, see the [development docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
|
||||
|
||||
### Developer Documentation
|
||||
|
||||
Please view the [documentation guide](./docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
|
||||
Please view the [documentation guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
|
||||
|
||||
## 👏 Shoutouts
|
||||
|
||||
@@ -168,7 +170,7 @@ Please view the [documentation guide](./docs/Developer%20Guide/Developer%20Guide
|
||||
## 🤝 Support
|
||||
|
||||
Support for the TriliumNext organization will be possible in the near future. For now, you can:
|
||||
- Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/Notes/graphs/contributors))) for a full list)
|
||||
- Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/trilium/graphs/contributors))) for a full list)
|
||||
- Show a token of gratitude to the original Trilium developer ([zadam](https://github.com/sponsors/zadam)) via [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2).
|
||||
|
||||
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.54.2",
|
||||
"@playwright/test": "1.55.0",
|
||||
"@stylistic/eslint-plugin": "5.2.3",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.17.1",
|
||||
"@types/node": "22.17.2",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.33.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.97.2",
|
||||
"version": "0.98.0",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -19,7 +19,7 @@
|
||||
"@fullcalendar/multimonth": "6.1.19",
|
||||
"@fullcalendar/timegrid": "6.1.19",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.1.8",
|
||||
"@mermaid-js/layout-elk": "0.1.9",
|
||||
"@mind-elixir/node-menu": "5.0.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
@@ -36,7 +36,7 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.50.1",
|
||||
"globals": "16.3.0",
|
||||
"i18next": "25.3.4",
|
||||
"i18next": "25.4.0",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
@@ -46,12 +46,13 @@
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.1.2",
|
||||
"mermaid": "11.9.0",
|
||||
"mind-elixir": "5.0.5",
|
||||
"marked": "16.2.0",
|
||||
"mermaid": "11.10.0",
|
||||
"mind-elixir": "5.0.6",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.27.0",
|
||||
"preact": "10.27.1",
|
||||
"react-i18next": "15.7.0",
|
||||
"split.js": "1.6.5",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
|
||||
@@ -35,8 +35,10 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
loadResults.addOption(attributeEntity.name);
|
||||
} else if (ec.entityName === "attachments") {
|
||||
processAttachment(loadResults, ec);
|
||||
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
|
||||
} else if (ec.entityName === "blobs") {
|
||||
// NOOP - these entities are handled at the backend level and don't require frontend processing
|
||||
} else if (ec.entityName === "etapi_tokens") {
|
||||
loadResults.hasEtapiTokenChanges = true;
|
||||
} else {
|
||||
throw new Error(`Unknown entityName '${ec.entityName}'`);
|
||||
}
|
||||
@@ -77,9 +79,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
noteAttributeCache.invalidate();
|
||||
}
|
||||
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const appContext = (await import("../components/app_context.js")).default as any;
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
await appContext.triggerEvent("entitiesReloaded", { loadResults });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import i18next from "i18next";
|
||||
import i18nextHttpBackend from "i18next-http-backend";
|
||||
import server from "./server.js";
|
||||
import type { Locale } from "@triliumnext/commons";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
@@ -16,6 +17,7 @@ export async function initLocale() {
|
||||
|
||||
locales = await server.get<Locale[]>("options/locales");
|
||||
|
||||
i18next.use(initReactI18next);
|
||||
await i18next.use(i18nextHttpBackend).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AttachmentRow } from "@triliumnext/commons";
|
||||
import type { AttachmentRow, EtapiTokenRow } from "@triliumnext/commons";
|
||||
import type { AttributeType } from "../entities/fattribute.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
|
||||
@@ -53,6 +53,7 @@ type EntityRowMappings = {
|
||||
options: OptionRow;
|
||||
revisions: RevisionRow;
|
||||
note_reordering: NoteReorderingRow;
|
||||
etapi_tokens: EtapiTokenRow;
|
||||
};
|
||||
|
||||
export type EntityRowNames = keyof EntityRowMappings;
|
||||
@@ -68,6 +69,7 @@ export default class LoadResults {
|
||||
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
|
||||
private optionNames: string[];
|
||||
private attachmentRows: AttachmentRow[];
|
||||
public hasEtapiTokenChanges: boolean = false;
|
||||
|
||||
constructor(entityChanges: EntityChange[]) {
|
||||
const entities: Record<string, Record<string, any>> = {};
|
||||
@@ -215,7 +217,8 @@ export default class LoadResults {
|
||||
this.revisionRows.length === 0 &&
|
||||
this.contentNoteIdToComponentId.length === 0 &&
|
||||
this.optionNames.length === 0 &&
|
||||
this.attachmentRows.length === 0
|
||||
this.attachmentRows.length === 0 &&
|
||||
!this.hasEtapiTokenChanges
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { OptionNames } from "@triliumnext/commons";
|
||||
import server from "./server.js";
|
||||
import { isShare } from "./utils.js";
|
||||
|
||||
type OptionValue = number | string;
|
||||
export type OptionValue = number | string;
|
||||
|
||||
class Options {
|
||||
initializedPromise: Promise<void>;
|
||||
@@ -76,6 +77,14 @@ class Options {
|
||||
await server.put(`options`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves multiple options at once, by supplying a record where the keys are the option names and the values represent the stringified value to set.
|
||||
* @param newValues the record of keys and values.
|
||||
*/
|
||||
async saveMany<T extends OptionNames>(newValues: Record<T, OptionValue>) {
|
||||
await server.put<void>("options", newValues);
|
||||
}
|
||||
|
||||
async toggle(key: string) {
|
||||
await this.save(key, (!this.is(key)).toString());
|
||||
}
|
||||
|
||||
@@ -14,6 +14,32 @@ interface ShortcutBinding {
|
||||
// Store all active shortcut bindings for management
|
||||
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
|
||||
|
||||
// Handle special key mappings and aliases
|
||||
const keyMap: { [key: string]: string[] } = {
|
||||
'return': ['Enter'],
|
||||
'enter': ['Enter'], // alias for return
|
||||
'del': ['Delete'],
|
||||
'delete': ['Delete'], // alias for del
|
||||
'esc': ['Escape'],
|
||||
'escape': ['Escape'], // alias for esc
|
||||
'space': [' ', 'Space'],
|
||||
'tab': ['Tab'],
|
||||
'backspace': ['Backspace'],
|
||||
'home': ['Home'],
|
||||
'end': ['End'],
|
||||
'pageup': ['PageUp'],
|
||||
'pagedown': ['PageDown'],
|
||||
'up': ['ArrowUp'],
|
||||
'down': ['ArrowDown'],
|
||||
'left': ['ArrowLeft'],
|
||||
'right': ['ArrowRight']
|
||||
};
|
||||
|
||||
// Function keys
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
keyMap[`f${i}`] = [`F${i}`];
|
||||
}
|
||||
|
||||
function removeGlobalShortcut(namespace: string) {
|
||||
bindGlobalShortcut("", null, namespace);
|
||||
}
|
||||
@@ -124,32 +150,6 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle special key mappings and aliases
|
||||
const keyMap: { [key: string]: string[] } = {
|
||||
'return': ['Enter'],
|
||||
'enter': ['Enter'], // alias for return
|
||||
'del': ['Delete'],
|
||||
'delete': ['Delete'], // alias for del
|
||||
'esc': ['Escape'],
|
||||
'escape': ['Escape'], // alias for esc
|
||||
'space': [' ', 'Space'],
|
||||
'tab': ['Tab'],
|
||||
'backspace': ['Backspace'],
|
||||
'home': ['Home'],
|
||||
'end': ['End'],
|
||||
'pageup': ['PageUp'],
|
||||
'pagedown': ['PageDown'],
|
||||
'up': ['ArrowUp'],
|
||||
'down': ['ArrowDown'],
|
||||
'left': ['ArrowLeft'],
|
||||
'right': ['ArrowRight']
|
||||
};
|
||||
|
||||
// Function keys
|
||||
for (let i = 1; i <= 19; i++) {
|
||||
keyMap[`f${i}`] = [`F${i}`];
|
||||
}
|
||||
|
||||
const mappedKeys = keyMap[key.toLowerCase()];
|
||||
if (mappedKeys) {
|
||||
return mappedKeys.includes(e.key) || mappedKeys.includes(e.code);
|
||||
@@ -163,7 +163,7 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
|
||||
// For letter keys, use the physical key code for consistency
|
||||
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
||||
return e.code === `Key${key.toUpperCase()}`;
|
||||
return e.key.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
|
||||
// For regular keys, check both key and code as fallback
|
||||
|
||||
@@ -5,7 +5,7 @@ const SVG_MIME = "image/svg+xml";
|
||||
|
||||
export const isShare = !window.glob;
|
||||
|
||||
function reloadFrontendApp(reason?: string) {
|
||||
export function reloadFrontendApp(reason?: string) {
|
||||
if (reason) {
|
||||
logInfo(`Frontend app reload: ${reason}`);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ function reloadFrontendApp(reason?: string) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function restartDesktopApp() {
|
||||
export function restartDesktopApp() {
|
||||
if (!isElectron()) {
|
||||
reloadFrontendApp();
|
||||
return;
|
||||
@@ -125,7 +125,7 @@ function formatDateISO(date: Date) {
|
||||
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
|
||||
}
|
||||
|
||||
function formatDateTime(date: Date, userSuppliedFormat?: string): string {
|
||||
export function formatDateTime(date: Date, userSuppliedFormat?: string): string {
|
||||
if (userSuppliedFormat?.trim()) {
|
||||
return dayjs(date).format(userSuppliedFormat);
|
||||
} else {
|
||||
@@ -144,7 +144,7 @@ function now() {
|
||||
/**
|
||||
* Returns `true` if the client is currently running under Electron, or `false` if running in a web browser.
|
||||
*/
|
||||
function isElectron() {
|
||||
export function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ function randomString(len: number) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
export function isMobile() {
|
||||
return (
|
||||
window.glob?.device === "mobile" ||
|
||||
// window.glob.device is not available in setup
|
||||
@@ -306,7 +306,7 @@ function copySelectionToClipboard() {
|
||||
}
|
||||
}
|
||||
|
||||
function dynamicRequire(moduleName: string) {
|
||||
export function dynamicRequire(moduleName: string) {
|
||||
if (typeof __non_webpack_require__ !== "undefined") {
|
||||
return __non_webpack_require__(moduleName);
|
||||
} else {
|
||||
@@ -374,33 +374,42 @@ async function openInAppHelp($button: JQuery<HTMLElement>) {
|
||||
|
||||
const inAppHelpPage = $button.attr("data-in-app-help");
|
||||
if (inAppHelpPage) {
|
||||
// Dynamic import to avoid import issues in tests.
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext) {
|
||||
return;
|
||||
}
|
||||
const subContexts = activeContext.getSubContexts();
|
||||
const targetNote = `_help_${inAppHelpPage}`;
|
||||
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
|
||||
const viewScope: ViewScope = {
|
||||
viewMode: "contextual-help",
|
||||
};
|
||||
if (!helpSubcontext) {
|
||||
// The help is not already open, open a new split with it.
|
||||
const { ntxId } = subContexts[subContexts.length - 1];
|
||||
appContext.triggerCommand("openNewNoteSplit", {
|
||||
ntxId,
|
||||
notePath: targetNote,
|
||||
hoistedNoteId: "_help",
|
||||
viewScope
|
||||
})
|
||||
} else {
|
||||
// There is already a help window open, make sure it opens on the right note.
|
||||
helpSubcontext.setNote(targetNote, { viewScope });
|
||||
}
|
||||
openInAppHelpFromUrl(inAppHelpPage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the in-app help at the given page in a split note. If there already is a split note open with a help page, it will be replaced by this one.
|
||||
*
|
||||
* @param inAppHelpPage the ID of the help note (excluding the `_help_` prefix).
|
||||
* @returns a promise that resolves once the help has been opened.
|
||||
*/
|
||||
export async function openInAppHelpFromUrl(inAppHelpPage: string) {
|
||||
// Dynamic import to avoid import issues in tests.
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext) {
|
||||
return;
|
||||
}
|
||||
const subContexts = activeContext.getSubContexts();
|
||||
const targetNote = `_help_${inAppHelpPage}`;
|
||||
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
|
||||
const viewScope: ViewScope = {
|
||||
viewMode: "contextual-help",
|
||||
};
|
||||
if (!helpSubcontext) {
|
||||
// The help is not already open, open a new split with it.
|
||||
const { ntxId } = subContexts[subContexts.length - 1];
|
||||
appContext.triggerCommand("openNewNoteSplit", {
|
||||
ntxId,
|
||||
notePath: targetNote,
|
||||
hoistedNoteId: "_help",
|
||||
viewScope
|
||||
})
|
||||
} else {
|
||||
// There is already a help window open, make sure it opens on the right note.
|
||||
helpSubcontext.setNote(targetNote, { viewScope });
|
||||
}
|
||||
}
|
||||
|
||||
function initHelpButtons($el: JQuery<HTMLElement> | JQuery<Window>) {
|
||||
@@ -735,6 +744,50 @@ function isLaunchBarConfig(noteId: string) {
|
||||
return ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers", "_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a class to the <body> of the page, where the class name is formed via a prefix and a value.
|
||||
* Useful for configurable options such as `heading-style-markdown`, where `heading-style` is the prefix and `markdown` is the dynamic value.
|
||||
* There is no separator between the prefix and the value, if needed it has to be supplied manually to the prefix.
|
||||
*
|
||||
* @param prefix the prefix.
|
||||
* @param value the value to be appended to the prefix.
|
||||
*/
|
||||
export function toggleBodyClass(prefix: string, value: string) {
|
||||
const $body = $("body");
|
||||
for (const clazz of Array.from($body[0].classList)) {
|
||||
// create copy to safely iterate over while removing classes
|
||||
if (clazz.startsWith(prefix)) {
|
||||
$body.removeClass(clazz);
|
||||
}
|
||||
}
|
||||
|
||||
$body.addClass(prefix + value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic comparison for equality between the two arrays. The values are strictly checked via `===`.
|
||||
*
|
||||
* @param a the first array to compare.
|
||||
* @param b the second array to compare.
|
||||
* @returns `true` if both arrays are equals, `false` otherwise.
|
||||
*/
|
||||
export function arrayEqual<T>(a: T[], b: T[]) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i=0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default {
|
||||
reloadFrontendApp,
|
||||
restartDesktopApp,
|
||||
|
||||
@@ -28,6 +28,21 @@
|
||||
--ck-mention-list-max-height: 500px;
|
||||
}
|
||||
|
||||
body#trilium-app.motion-disabled *,
|
||||
body#trilium-app.motion-disabled *::before,
|
||||
body#trilium-app.motion-disabled *::after {
|
||||
/* Disable transitions and animations */
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
body#trilium-app.shadows-disabled *,
|
||||
body#trilium-app.shadows-disabled *::before,
|
||||
body#trilium-app.shadows-disabled *::after {
|
||||
/* Disable shadows */
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
--bs-table-bg: transparent !important;
|
||||
}
|
||||
@@ -139,6 +154,15 @@ textarea,
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.form-group.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Add a gap between consecutive radios / check boxes */
|
||||
label.tn-radio + label.tn-radio,
|
||||
label.tn-checkbox + label.tn-checkbox {
|
||||
@@ -346,7 +370,7 @@ body.desktop .tabulator-popup-container {
|
||||
|
||||
@supports (animation-fill-mode: forwards) {
|
||||
/* Delay the opening of submenus */
|
||||
body.desktop .dropdown-submenu .dropdown-menu {
|
||||
body.desktop:not(.motion-disabled) .dropdown-submenu .dropdown-menu {
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: var(--submenu-opening-delay);
|
||||
@@ -1738,16 +1762,12 @@ button.close:hover {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.options-number-input {
|
||||
.options-section input[type="number"] {
|
||||
/* overriding settings from .form-control */
|
||||
width: 10em !important;
|
||||
flex-grow: 0 !important;
|
||||
}
|
||||
|
||||
.options-mime-types {
|
||||
column-width: 250px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
@@ -182,8 +182,6 @@ div.note-detail-empty {
|
||||
|
||||
.options-section:not(.tn-no-card) {
|
||||
margin: auto;
|
||||
min-width: var(--options-card-min-width);
|
||||
max-width: var(--options-card-max-width);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--card-border-color) !important;
|
||||
box-shadow: var(--card-box-shadow);
|
||||
@@ -192,6 +190,11 @@ div.note-detail-empty {
|
||||
margin-bottom: calc(var(--options-title-offset) + 26px) !important;
|
||||
}
|
||||
|
||||
body.desktop .option-section:not(.tn-no-card) {
|
||||
min-width: var(--options-card-min-width);
|
||||
max-width: var(--options-card-max-width);
|
||||
}
|
||||
|
||||
.note-detail-content-widget-content.options {
|
||||
--default-padding: 15px;
|
||||
padding-top: calc(var(--default-padding) + var(--options-title-offset) + var(--options-title-font-size));
|
||||
@@ -233,11 +236,6 @@ div.note-detail-empty {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.options-section .options-mime-types {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.options-section .form-group {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
@@ -967,7 +967,7 @@
|
||||
},
|
||||
"protected_session": {
|
||||
"enter_password_instruction": "显示受保护的笔记需要输入您的密码:",
|
||||
"start_session_button": "开始受保护的会话",
|
||||
"start_session_button": "开始受保护的会话 <kbd>Enter</kbd>",
|
||||
"started": "受保护的会话已启动。",
|
||||
"wrong_password": "密码错误。",
|
||||
"protecting-finished-successfully": "保护操作已成功完成。",
|
||||
@@ -1028,7 +1028,7 @@
|
||||
"error_creating_anonymized_database": "无法创建匿名化数据库,请检查后端日志以获取详细信息",
|
||||
"successfully_created_fully_anonymized_database": "成功创建完全匿名化的数据库,路径为 {{anonymizedFilePath}}",
|
||||
"successfully_created_lightly_anonymized_database": "成功创建轻度匿名化的数据库,路径为 {{anonymizedFilePath}}",
|
||||
"no_anonymized_database_yet": "尚无匿名化数据库"
|
||||
"no_anonymized_database_yet": "尚无匿名化数据库。"
|
||||
},
|
||||
"database_integrity_check": {
|
||||
"title": "数据库完整性检查",
|
||||
@@ -1165,7 +1165,7 @@
|
||||
},
|
||||
"revisions_snapshot_interval": {
|
||||
"note_revisions_snapshot_interval_title": "笔记修订快照间隔",
|
||||
"note_revisions_snapshot_description": "笔记修订快照间隔是创建新笔记修订的时间。有关更多信息,请参见 <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a>。",
|
||||
"note_revisions_snapshot_description": "笔记修订快照间隔是创建新笔记修订的时间。有关更多信息,请参见 <doc>wiki</doc>。",
|
||||
"snapshot_time_interval_label": "笔记修订快照时间间隔:"
|
||||
},
|
||||
"revisions_snapshot_limit": {
|
||||
@@ -1333,9 +1333,9 @@
|
||||
"oauth_title": "OAuth/OpenID 认证",
|
||||
"oauth_description": "OpenID 是一种标准化方式,允许您使用其他服务(如 Google)的账号登录网站来验证您的身份。默认的身份提供者是 Google,但您可以更改为任何其他 OpenID 提供者。点击<a href=\"#root/_hidden/_help/_help_Otzi9La2YAUX/_help_WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz\">这里</a>了解更多信息。请参阅这些 <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">指南</a> 通过 Google 设置 OpenID 服务。",
|
||||
"oauth_description_warning": "要启用 OAuth/OpenID,您需要设置 config.ini 文件中的 OAuth/OpenID 基础 URL、客户端 ID 和客户端密钥,并重新启动应用程序。如果要从环境变量设置,请设置 TRILIUM_OAUTH_BASE_URL、TRILIUM_OAUTH_CLIENT_ID 和 TRILIUM_OAUTH_CLIENT_SECRET 环境变量。",
|
||||
"oauth_missing_vars": "缺少以下设置项: {{missingVars}}",
|
||||
"oauth_user_account": "用户账号:",
|
||||
"oauth_user_email": "用户邮箱:",
|
||||
"oauth_missing_vars": "缺少以下设置项:{{variables}}",
|
||||
"oauth_user_account": "用户账号: ",
|
||||
"oauth_user_email": "用户邮箱: ",
|
||||
"oauth_user_not_logged_in": "未登录!"
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -1357,7 +1357,7 @@
|
||||
"enable": "启用拼写检查",
|
||||
"language_code_label": "语言代码",
|
||||
"language_code_placeholder": "例如 \"en-US\", \"de-AT\"",
|
||||
"multiple_languages_info": "多种语言可以用逗号分隔,例如 \"en-US, de-DE, cs\"。",
|
||||
"multiple_languages_info": "多种语言可以用逗号分隔,例如 \"en-US, de-DE, cs\"。 ",
|
||||
"available_language_codes_label": "可用的语言代码:",
|
||||
"restart-required": "拼写检查选项的更改将在应用重启后生效。"
|
||||
},
|
||||
@@ -1878,7 +1878,7 @@
|
||||
},
|
||||
"custom_date_time_format": {
|
||||
"title": "自定义日期/时间格式",
|
||||
"description": "通过<kbd></kbd>或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a>。",
|
||||
"description": "通过<shortcut />或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<doc>Day.js docs</doc>。",
|
||||
"format_string": "日期/时间格式字符串:",
|
||||
"formatted_time": "格式化后日期/时间:"
|
||||
},
|
||||
@@ -1992,11 +1992,12 @@
|
||||
"help_title": "显示关于此画面的更多信息"
|
||||
},
|
||||
"call_to_action": {
|
||||
"next_theme_title": "新的 Trilium 主题已进入稳定版",
|
||||
"next_theme_message": "有一段时间,我们一直在设计新的主题,为了让应用程序看起来更加现代。",
|
||||
"next_theme_button": "切换至新的 Trilium 主题",
|
||||
"background_effects_title": "背景效果现已推出稳定版本",
|
||||
"background_effects_message": "在 Windows 装置上,背景效果现在已完全稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。此技术也用于其他应用程序,例如 Windows 资源管理器。",
|
||||
"background_effects_button": "启用背景效果"
|
||||
"background_effects_button": "启用背景效果",
|
||||
"next_theme_title": "试用新 Trilium 主题",
|
||||
"next_theme_message": "当前使用旧版主题,要试用新主题吗?",
|
||||
"next_theme_button": "试用新主题",
|
||||
"dismiss": "关闭"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1113,6 +1113,11 @@
|
||||
"layout-vertical-description": "launcher bar is on the left (default)",
|
||||
"layout-horizontal-description": "launcher bar is underneath the tab bar, the tab bar is now full width."
|
||||
},
|
||||
"ui-performance": {
|
||||
"title": "Performance",
|
||||
"enable-motion": "Enable transitions and animations",
|
||||
"enable-shadows": "Enable shadows"
|
||||
},
|
||||
"ai_llm": {
|
||||
"not_started": "Not started",
|
||||
"title": "AI Settings",
|
||||
@@ -1253,7 +1258,12 @@
|
||||
"selected_provider": "Selected Provider",
|
||||
"selected_provider_description": "Choose the AI provider for chat and completion features",
|
||||
"select_model": "Select model...",
|
||||
"select_provider": "Select provider..."
|
||||
"select_provider": "Select provider...",
|
||||
"ai_enabled": "AI features enabled",
|
||||
"ai_disabled": "AI features disabled",
|
||||
"no_models_found_online": "No models found. Please check your API key and settings.",
|
||||
"no_models_found_ollama": "No Ollama models found. Please check if Ollama is running.",
|
||||
"error_fetching": "Error fetching models: {{error}}"
|
||||
},
|
||||
"zoom_factor": {
|
||||
"title": "Zoom Factor (desktop build only)",
|
||||
@@ -1310,7 +1320,7 @@
|
||||
},
|
||||
"revisions_snapshot_interval": {
|
||||
"note_revisions_snapshot_interval_title": "Note Revision Snapshot Interval",
|
||||
"note_revisions_snapshot_description": "The Note revision snapshot interval is the time after which a new note revision will be created for the note. See <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a> for more info.",
|
||||
"note_revisions_snapshot_description": "The Note revision snapshot interval is the time after which a new note revision will be created for the note. See <doc>wiki</doc> for more info.",
|
||||
"snapshot_time_interval_label": "Note revision snapshot time interval:"
|
||||
},
|
||||
"revisions_snapshot_limit": {
|
||||
@@ -1372,7 +1382,7 @@
|
||||
},
|
||||
"custom_date_time_format": {
|
||||
"title": "Custom Date/Time Format",
|
||||
"description": "Customize the format of the date and time inserted via <kbd></kbd> or the toolbar. See <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a> for available format tokens.",
|
||||
"description": "Customize the format of the date and time inserted via <shortcut /> or the toolbar. See <doc>Day.js docs</doc> for available format tokens.",
|
||||
"format_string": "Format string:",
|
||||
"formatted_time": "Formatted date/time:"
|
||||
},
|
||||
@@ -1994,11 +2004,22 @@
|
||||
"help_title": "Display more information about this screen"
|
||||
},
|
||||
"call_to_action": {
|
||||
"next_theme_title": "The new Trilium theme is now stable",
|
||||
"next_theme_message": "For a while now, we've been working on a new theme to give the application a more modern look.",
|
||||
"next_theme_button": "Switch to the new Trilium theme",
|
||||
"next_theme_title": "Try the new Trilium theme",
|
||||
"next_theme_message": "You are currently using the legacy theme, would you like to try the new theme?",
|
||||
"next_theme_button": "Try the new theme",
|
||||
"background_effects_title": "Background effects are now stable",
|
||||
"background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.",
|
||||
"background_effects_button": "Enable background effects"
|
||||
"background_effects_button": "Enable background effects",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"settings": {
|
||||
"related_settings": "Related settings"
|
||||
},
|
||||
"settings_appearance": {
|
||||
"related_code_blocks": "Color scheme for code blocks in text notes",
|
||||
"related_code_notes": "Color scheme for code notes"
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
22
apps/client/src/translations/fa/translation.json
Normal file
22
apps/client/src/translations/fa/translation.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "درباره Trilium Notes",
|
||||
"homepage": "صفحه اصلی:",
|
||||
"app_version": "نسخه برنامه:",
|
||||
"db_version": "نسخه پایگاه داده:",
|
||||
"sync_version": "نسخه منطبق:",
|
||||
"build_date": "تاریخ ساخت:",
|
||||
"build_revision": "نسخه بازنگری شده:",
|
||||
"data_directory": "دایرکتوری داده:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "خطای بحرانی",
|
||||
"message": "خطای بحرانی رخ داده که مانع از اجرای برنامه می شود\n\n {{message}}\n\nبه احتمال زیاد ناشی از خطای غیرمنتظره در اجرای ناموفق یک اسکریپت است. برنامه را در مد ایمن اجرا کنید و خطا را بررسی نمایید."
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "افزودن لینک",
|
||||
"note": "یادداشت"
|
||||
}
|
||||
}
|
||||
147
apps/client/src/translations/fi/translation.json
Normal file
147
apps/client/src/translations/fi/translation.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Lisätietoja Trilium Notes:ista",
|
||||
"homepage": "Kotisivu:",
|
||||
"app_version": "Sovelluksen versio:",
|
||||
"db_version": "Tietokannan versio:",
|
||||
"build_date": "Koontipäivämäärä:",
|
||||
"data_directory": "Datakansio:",
|
||||
"sync_version": "Synkronoinnin versio:",
|
||||
"build_revision": "Sovelluksen versio:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kriittinen virhe"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Widgetin luonti epäonnistui"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Lisää linkki",
|
||||
"link_title": "Linkin otsikko",
|
||||
"button_add_link": "Lisää linkki",
|
||||
"note": "Muistio",
|
||||
"search_note": "etsi muistiota sen nimellä"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"prefix": "Etuliite: ",
|
||||
"save": "Tallenna"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Massatoiminnot",
|
||||
"available_actions": "Saatavilla olevat toiminnot",
|
||||
"chosen_actions": "Valitut toiminnot",
|
||||
"execute_bulk_actions": "Toteuta massatoiminnot",
|
||||
"bulk_actions_executed": "Massatoiminnot on toteutettu onnistuneesti.",
|
||||
"none_yet": "Ei vielä... lisää toiminto klikkaamalla jotiain yllä saatavilla olevaa yltä.",
|
||||
"labels": "Merkit",
|
||||
"relations": "Suhteet",
|
||||
"notes": "Muistiot",
|
||||
"other": "Muut",
|
||||
"affected_notes": "Vaikuttaa muistioihin"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Kopioi muistiot...",
|
||||
"help_on_links": "Apua linkkeihin",
|
||||
"notes_to_clone": "Kopioitavat muistiot",
|
||||
"target_parent_note": "Kohteen päämuistio",
|
||||
"search_for_note_by_its_name": "ensi muistiota sen nimellä",
|
||||
"cloned_note_prefix_title": "Kopioitu muistia näytetään puussa annetulla etuliitteellä",
|
||||
"prefix_optional": "Etuliite (valinnainen)",
|
||||
"clone_to_selected_note": "Kopioi valittuun muistioon",
|
||||
"note_cloned": "Muistio \"{{clonedTitle}}\" on kopioitu \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Vahvistus",
|
||||
"cancel": "Peruuta",
|
||||
"ok": "OK",
|
||||
"also_delete_note": "Poista myös muistio"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Poista muistion esikatselu",
|
||||
"close": "Sulje",
|
||||
"notes_to_be_deleted": "Seuraavat muistiot tullaan poistamaan ({{notesCount}})",
|
||||
"no_note_to_delete": "Muistioita ei poisteta (vain kopiot).",
|
||||
"cancel": "Peruuta",
|
||||
"ok": "OK"
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "Vie muistio",
|
||||
"close": "Sulje",
|
||||
"format_html": "HTML - suositeltu, sillä se säilyttää kaikki formatoinnit",
|
||||
"format_markdown": "Markdown - tämä säilyttää suurimman osan formatoinneista.",
|
||||
"opml_version_1": "OPML v1.0 - pelkkä teksti",
|
||||
"opml_version_2": "OPML v2.0 - sallii myös HTML:n",
|
||||
"export": "Vie",
|
||||
"choose_export_type": "Valitse ensin viennin tyyppi",
|
||||
"export_status": "Viennin tila",
|
||||
"export_in_progress": "Vienti käynnissä: {{progressCount}}",
|
||||
"export_finished_successfully": "Vienti valmistui onnistuneesti.",
|
||||
"format_pdf": "PDF - tulostukseen ja jakamiseen."
|
||||
},
|
||||
"help": {
|
||||
"title": "Lunttilappu",
|
||||
"noteNavigation": "Muistion navigointi",
|
||||
"goUpDown": "mene ylös/alas muistioiden listassa",
|
||||
"collapseExpand": "pienennä/suurenna solmu",
|
||||
"notSet": "ei asetettu",
|
||||
"goBackForwards": "mene taaksepäin/eteenpäin historiassa",
|
||||
"jumpToParentNote": "Hyppää ylempään muistioon",
|
||||
"collapseWholeTree": "pienennä koko muistio puu",
|
||||
"onlyInDesktop": "Vain työpöytänäkymässä (Electron build)",
|
||||
"openEmptyTab": "Avaa tyhjä välilehti",
|
||||
"closeActiveTab": "sulje aktiivinen välilehti",
|
||||
"activateNextTab": "aktivoi seuraava välilehti",
|
||||
"activatePreviousTab": "aktivoi edellinen välilehti",
|
||||
"creatingNotes": "Luo muistiota",
|
||||
"movingCloningNotes": "Siirrä / kopioi muistioita",
|
||||
"moveNoteUpHierarchy": "siirrä muistio ylöspäin listassa",
|
||||
"selectNote": "valitse muistio",
|
||||
"editingNotes": "Muokkaa solmua",
|
||||
"createEditLink": "luo / muokkaa ulkoista linkkiä",
|
||||
"createInternalLink": "luo sisäinen linkki",
|
||||
"insertDateTime": "lisää nykyinen päivämäärä ja aika hiiren kohdalle",
|
||||
"troubleshooting": "Vianmääritys",
|
||||
"reloadFrontend": "lataa Trilium:in käyttöliittymä",
|
||||
"showDevTools": "näytä kehittäjätyökalut",
|
||||
"showSQLConsole": "näytä SQL konsoli",
|
||||
"other": "Muut"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Tuo muistioon",
|
||||
"chooseImportFile": "Valitse tuonnin tiedosto",
|
||||
"options": "Valinnat",
|
||||
"safeImport": "Turvallinen tuonti",
|
||||
"shrinkImages": "Kutista kuvat",
|
||||
"replaceUnderscoresWithSpaces": "Korvaa alaviivat väleillä tuotujen muistioiden tiedostonimissä",
|
||||
"import": "Tuo",
|
||||
"failed": "Tuonti epäonnistui: {{message}}.",
|
||||
"html_import_tags": {
|
||||
"title": "HTML Tuonnin Tunnisteet",
|
||||
"placeholder": "Lisää HTML tunnisteet, yksi per rivi"
|
||||
},
|
||||
"import-status": "Tuonnin tila",
|
||||
"in-progress": "Tuonti vaiheessa: {{progress}}",
|
||||
"successful": "Tuonti valmistui onnistuneesti."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Sisällytä muistio",
|
||||
"label_note": "Muistio",
|
||||
"placeholder_search": "etsi muistiota sen nimellä",
|
||||
"box_size_small": "pieni (~ 10 riviä)",
|
||||
"box_size_medium": "keskisuuri (~ 30 riviä)",
|
||||
"button_include": "Sisällytä muistio"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Info viesti",
|
||||
"closeButton": "Sulje",
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_button": "Etsi koko tekstistä"
|
||||
},
|
||||
"call_to_action": {
|
||||
"dismiss": "Hylkää"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1
apps/client/src/translations/hu/translation.json
Normal file
1
apps/client/src/translations/hu/translation.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
File diff suppressed because it is too large
Load Diff
1
apps/client/src/translations/ko/translation.json
Normal file
1
apps/client/src/translations/ko/translation.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
41
apps/client/src/translations/pl/translation.json
Normal file
41
apps/client/src/translations/pl/translation.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "O notatkach Trilium",
|
||||
"homepage": "Strona główna:",
|
||||
"app_version": "Wersja aplikacji:",
|
||||
"db_version": "Wersja bazy danych:",
|
||||
"sync_version": "Wersja synchronizacji:",
|
||||
"build_date": "Zbudowano:",
|
||||
"build_revision": "Rewizja zbudowania:",
|
||||
"data_directory": "Katalog z danymi:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Błąd krytyczny",
|
||||
"message": "Wystąpił krytyczny błąd uniemożliwiający uruchomienie aplikacji:\n\n{{message}}\n\nJest to spowodowane najprawdopodobniej niespodziewanym błędem skryptu. Spróbuj uruchomić aplikację ponownie w trybie bezpiecznym i zaadresuj problem."
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Dodaj link"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "Zapisz"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"labels": "Etykiety",
|
||||
"notes": "Notatki",
|
||||
"other": "Inne",
|
||||
"relations": "Powiązania"
|
||||
},
|
||||
"confirm": {
|
||||
"ok": "OK",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"delete_notes": {
|
||||
"cancel": "Anuluj",
|
||||
"close": "Zamknij"
|
||||
},
|
||||
"export": {
|
||||
"close": "Zamknij"
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"ok": "OK <kbd>enter</kbd>",
|
||||
"ok": "OK",
|
||||
"defaultTitle": "Prompt"
|
||||
},
|
||||
"protected_session_password": {
|
||||
@@ -257,7 +257,7 @@
|
||||
"help_title": "Ajuda sobre notas protegidas",
|
||||
"close_label": "Fechar",
|
||||
"form_label": "Para prosseguir com a ação solicitada, você precisa iniciar uma sessão protegida digitando a senha:",
|
||||
"start_button": "Iniciar sessão protegida <kbd>enter</kbd>"
|
||||
"start_button": "Iniciar sessão protegida"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Alterações recentes",
|
||||
@@ -306,12 +306,12 @@
|
||||
"sort_with_respect_to_different_character_sorting": "classificar de acordo com diferentes regras de ordenação de caracteres e colação em diferentes idiomas ou regiões.",
|
||||
"natural_sort_language": "Linguagem da ordenação natural",
|
||||
"the_language_code_for_natural_sort": "O código do idioma para ordenação natural, por exemplo, \"zh-CN\" para chinês.",
|
||||
"sort": "Ordenar <kbd>enter</kbd>"
|
||||
"sort": "Ordenar"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Enviar anexos para a nota",
|
||||
"choose_files": "Escolher arquivos",
|
||||
"files_will_be_uploaded": "Os arquivos serão enviados como anexos para",
|
||||
"files_will_be_uploaded": "Os arquivos serão enviados como anexos para {{noteTitle}}",
|
||||
"options": "Opções",
|
||||
"shrink_images": "Reduzir imagens",
|
||||
"upload": "Enviar",
|
||||
@@ -409,6 +409,11 @@
|
||||
"template": "Esta nota aparecerá na seleção de modelos disponíveis ao criar uma nova nota",
|
||||
"toc": "<code>#toc</code> ou <code>#toc=show</code> irá forçar a exibição do Sumário, <code>#toc=hide</code> irá forçar que ele fique oculto. Se o rótulo não existir, será considerado o ajuste global",
|
||||
"color": "define a cor da nota na árvore de notas, links etc. Use qualquer valor de cor CSS válido, como 'red' ou #a13d5f",
|
||||
"keyboard_shortcut": "Define um atalho de teclado que irá pular imediatamente para esta nota. Exemplo: 'ctrl+alt+e'. É necessário recarregar o frontend para que a alteração tenha efeito."
|
||||
"keyboard_shortcut": "Define um atalho de teclado que irá pular imediatamente para esta nota. Exemplo: 'ctrl+alt+e'. É necessário recarregar o frontend para que a alteração tenha efeito.",
|
||||
"hide_highlight_widget": "Ocultar o widget da lista de destaques",
|
||||
"keep_current_hoisting": "Abrir este link não alterará o destaque, mesmo que a nota não seja exibível na subárvore destacada atual.",
|
||||
"execute_button": "Titulo do botão que executará a nota de código atual",
|
||||
"exclude_from_note_map": "Notas com este rótulo ficarão ocultas no Mapa de Notas",
|
||||
"new_notes_on_top": "Novas notas serão criadas no topo da nota raiz, não na parte inferior."
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
730
apps/client/src/translations/uk/translation.json
Normal file
730
apps/client/src/translations/uk/translation.json
Normal file
@@ -0,0 +1,730 @@
|
||||
{
|
||||
"add_link": {
|
||||
"add_link": "Додати посилання",
|
||||
"help_on_links": "Довідка щодо посилань",
|
||||
"note": "Нотатка",
|
||||
"search_note": "пошук нотатки за її назвою",
|
||||
"link_title_mirrors": "заголовок посилання відображає поточний заголовок нотатки",
|
||||
"link_title_arbitrary": "заголовок посилання можна змінювати довільно",
|
||||
"link_title": "Заголовок посилання",
|
||||
"button_add_link": "Додати посилання"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "Зберегти",
|
||||
"edit_branch_prefix": "Редагувати префікс гілки",
|
||||
"help_on_tree_prefix": "Довідка щодо префіксу дерева",
|
||||
"prefix": "Префікс: ",
|
||||
"branch_prefix_saved": "Префікс гілки збережено."
|
||||
},
|
||||
"about": {
|
||||
"app_version": "Версія програми:",
|
||||
"db_version": "Версія БД:",
|
||||
"build_date": "Дата збірки:",
|
||||
"build_revision": "Ревізія збірки:",
|
||||
"data_directory": "Каталог даних:",
|
||||
"homepage": "Домашня сторінка:",
|
||||
"title": "Про Trilium Notes",
|
||||
"sync_version": "Версія синхронізації:"
|
||||
},
|
||||
"global_menu": {
|
||||
"about": "Про Trilium Notes",
|
||||
"menu": "Меню",
|
||||
"options": "Параметри",
|
||||
"open_new_window": "Відкрити Нове вікно",
|
||||
"switch_to_mobile_version": "Перейти на мобільну версію",
|
||||
"switch_to_desktop_version": "Перейти на версію для ПК",
|
||||
"zoom": "Масштаб",
|
||||
"toggle_fullscreen": "Увімкнути повноекранний режим",
|
||||
"zoom_out": "Зменшити масштаб",
|
||||
"reset_zoom_level": "Скинути масштабування",
|
||||
"zoom_in": "Збільшити масштаб",
|
||||
"configure_launchbar": "Налаштувати панель запуску",
|
||||
"show_shared_notes_subtree": "Показати піддерево спільних нотаток",
|
||||
"advanced": "Розширені",
|
||||
"open_dev_tools": "Відкрити інструменти розробника",
|
||||
"open_sql_console": "Відкрити консоль SQL",
|
||||
"open_sql_console_history": "Відкрити історію консолі SQL",
|
||||
"open_search_history": "Відкрити історію пошуку",
|
||||
"show_backend_log": "Показати Backend Log",
|
||||
"reload_hint": "Перезавантаження може допомогти з деякими візуальними збоями без перезавантаження всієї програми.",
|
||||
"reload_frontend": "Перезавантажити інтерфейс",
|
||||
"show_hidden_subtree": "Показати приховане піддерево",
|
||||
"show_help": "Показати довідку",
|
||||
"logout": "Вийти",
|
||||
"show-cheatsheet": "Показати Шпаргалку",
|
||||
"toggle-zen-mode": "Дзен-режим"
|
||||
},
|
||||
"modal": {
|
||||
"help_title": "Показати більше інформації про це вікно"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Критична помилка",
|
||||
"message": "Сталася критична помилка, яка перешкоджає запуску клієнтської програми:\n\n{{message}}\n\nНайімовірніше, це спричинено несподіваною помилкою скрипту. Спробуйте запустити програму в безпечному режимі та вирішити проблему."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Не вдалася ініціалізація віджета",
|
||||
"message-custom": "Не вдалося ініціалізувати користувацький віджет із нотатки з ID \"{{id}}\" під назвою \"{{title}}\" через:\n\n{{message}}",
|
||||
"message-unknown": "Невідомий віджет не вдалося ініціалізувати через:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Не вдалося завантажити користувацький скрипт",
|
||||
"message": "Скрипт з нотатки з ID \"{{id}}\" з заголовком \"{{title}}\" не вдалося виконати через:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Масові дії",
|
||||
"affected_notes": "Застосовані нотатки",
|
||||
"available_actions": "Доступні дії",
|
||||
"chosen_actions": "Обрані дії",
|
||||
"execute_bulk_actions": "Виконання масових дій",
|
||||
"bulk_actions_executed": "Масові дії успішно виконано.",
|
||||
"none_yet": "Поки що немає... додайте дію, натиснувши одну з доступних вище.",
|
||||
"include_descendants": "Включити нащадків вибраних нотаток",
|
||||
"labels": "Мітки",
|
||||
"relations": "Зв'язки",
|
||||
"notes": "Нотатки",
|
||||
"other": "Інше"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Клонувати нотатки до...",
|
||||
"target_parent_note": "Цільова батьківська нотатка",
|
||||
"search_for_note_by_its_name": "пошук нотатки за назвою",
|
||||
"help_on_links": "Довідка щодо посилань",
|
||||
"notes_to_clone": "Нотатки для клонування",
|
||||
"cloned_note_prefix_title": "Клонована нотатка буде відображатися в дереві нотаток із заданим префіксом",
|
||||
"prefix_optional": "Префікс (необов'язково)",
|
||||
"clone_to_selected_note": "Клонувати до вибраної нотатки",
|
||||
"no_path_to_clone_to": "Немає шляху для клонування.",
|
||||
"note_cloned": "Нотатку \"{{clonedTitle}}\" було клоновано в \"{{targetTitle}}\""
|
||||
},
|
||||
"clipboard": {
|
||||
"copied": "Нотатку(-и) було скопійовано в буфер.",
|
||||
"copy_failed": "Не вдалося скопіювати в буфер через проблеми з дозволами.",
|
||||
"copy_success": "Скопійовано в буфер."
|
||||
},
|
||||
"entrypoints": {
|
||||
"sql-error": "Виникла помилка при виконанні запиту SQL: {{message}}"
|
||||
},
|
||||
"branches": {
|
||||
"undeleting-notes-finished-successfully": "Нотатки вдало відновлено.",
|
||||
"undeleting-notes-in-progress": "Відновлюємо нотатки: {{count}}",
|
||||
"delete-notes-in-progress": "Видаляємо нотатки: {{count}}",
|
||||
"delete-finished-successfully": "Нотатки вдало видалено."
|
||||
},
|
||||
"launcher_context_menu": {
|
||||
"add-spacer": "Додати розділювач",
|
||||
"reset": "Скинути"
|
||||
},
|
||||
"editable-text": {
|
||||
"auto-detect-language": "Автовизначена"
|
||||
},
|
||||
"highlighting": {
|
||||
"color-scheme": "Схема кольорів"
|
||||
},
|
||||
"code_block": {
|
||||
"copy_title": "Скопіювати в буфер"
|
||||
},
|
||||
"classic_editor_toolbar": {
|
||||
"title": "Форматування"
|
||||
},
|
||||
"editor": {
|
||||
"title": "Редактор"
|
||||
},
|
||||
"editing": {
|
||||
"editor_type": {
|
||||
"label": "Панель інструментів форматування"
|
||||
}
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Підтвердження",
|
||||
"cancel": "Скасувати",
|
||||
"ok": "ОК",
|
||||
"are_you_sure_remove_note": "Ви впевнені, що хочете видалити нотатку \"{{title}}\" з карти зв'язків? ",
|
||||
"if_you_dont_check": "Якщо ви не позначите цей пункт, нотатку буде видалено лише з карти зв'язків.",
|
||||
"also_delete_note": "Також видалити нотатку"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Видалити попередній перегляд нотаток",
|
||||
"close": "Закрити",
|
||||
"delete_all_clones_description": "Видалити також усі клони (можна скасувати в останніх змінах)",
|
||||
"erase_notes_description": "Звичайне (м’яке) видалення лише позначає нотатки як видалені і їх можна відновити (у діалоговому вікні останніх змін) протягом певного періоду часу. Якщо позначити цю опцію, нотатки будуть видалені негайно і їх неможливо буде відновити.",
|
||||
"erase_notes_warning": "Стерти нотатки назавжди (скасувати не можна), включаючи всі клони. Це призведе до перезавантаження програми.",
|
||||
"notes_to_be_deleted": "Наступні нотатки будуть видалені ({{notesCount}})",
|
||||
"no_note_to_delete": "Жодну нотатку не буде видалено (лише клони).",
|
||||
"broken_relations_to_be_deleted": "Наступні зв'язки будуть розірвані та видалені ({{ relationCount}})",
|
||||
"cancel": "Скасувати",
|
||||
"ok": "ОК",
|
||||
"deleted_relation_text": "Нотатка {{- note}} (буде видалена) посилається на зв'язок {{- relation}}, що походить з {{- source}}."
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "Експорт нотатки",
|
||||
"close": "Закрити",
|
||||
"export_type_subtree": "Ця нотатка та всі її нащадки",
|
||||
"format_html": "HTML – рекомендовано, оскільки зберігає форматування",
|
||||
"format_html_zip": "HTML у ZIP-архіві – рекомендовано, зберігає форматування.",
|
||||
"format_markdown": "Markdown – зберігає більшу частину форматування.",
|
||||
"format_opml": "OPML – формат обміну структурами лише для тексту. Форматування, зображення та файли не включено.",
|
||||
"opml_version_1": "OPML версії 1.0 – лише звичайний текст",
|
||||
"opml_version_2": "OPML v2.0 - також дозволяє HTML",
|
||||
"export_type_single": "Тільки ця нотатка без її нащадків",
|
||||
"export": "Експорт",
|
||||
"choose_export_type": "Спочатку виберіть тип експорту",
|
||||
"export_status": "Статус експорту",
|
||||
"export_in_progress": "Триває експорт: {{progressCount}}",
|
||||
"export_finished_successfully": "Експорт успішно завершено.",
|
||||
"format_pdf": "PDF – для друку або спільного використання."
|
||||
},
|
||||
"help": {
|
||||
"title": "Шпаргалка",
|
||||
"noteNavigation": "Навігація по нотатках",
|
||||
"goUpDown": "переміститись вгору/вниз у списку нотаток",
|
||||
"collapseExpand": "згорнути/розгорнути вузол",
|
||||
"notSet": "не встановлено",
|
||||
"goBackForwards": "повернутися назад / вперед в історії",
|
||||
"showJumpToNoteDialog": "показати <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">діалогове вікно \"Перейти до\"</a>",
|
||||
"scrollToActiveNote": "прокрутити до активної нотатки",
|
||||
"jumpToParentNote": "перейти до батьківської нотатки",
|
||||
"collapseWholeTree": "згорнути все дерево нотаток",
|
||||
"collapseSubTree": "згорнути піддерево",
|
||||
"tabShortcuts": "Швидкі клавіші вкладки",
|
||||
"newTabNoteLink": "посилання на нотатку відкриває нотатку в новій вкладці",
|
||||
"newTabWithActivationNoteLink": "посилання на нотатку відкривається та активує нотатку в новій вкладці",
|
||||
"onlyInDesktop": "Тільки для ПК (збірка Electron)",
|
||||
"openEmptyTab": "відкрити порожню вкладку",
|
||||
"closeActiveTab": "закрити активну вкладку",
|
||||
"activateNextTab": "активувати наступну вкладку",
|
||||
"activatePreviousTab": "активувати попередню вкладку",
|
||||
"creatingNotes": "Створення нотаток",
|
||||
"createNoteAfter": "створити нову нотатку після активної нотатки",
|
||||
"createNoteInto": "створити нову піднотатку в активній нотатці",
|
||||
"editBranchPrefix": "редагувати <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">префікс</a> активного клону нотатки",
|
||||
"movingCloningNotes": "Переміщення / клонування нотаток",
|
||||
"moveNoteUpDown": "переміщення нотатки вгору/вниз у списку нотаток",
|
||||
"moveNoteUpHierarchy": "перемістити нотатку вище в ієрархії",
|
||||
"multiSelectNote": "множинний вибір нотатки вище/нижче",
|
||||
"selectAllNotes": "вибрати всі нотатки на поточному рівні",
|
||||
"selectNote": "вибрати нотатку",
|
||||
"copyNotes": "копіювати активну нотатку (або поточний вибір) у буфер обміну (використовується для <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">клонування</a>)",
|
||||
"cutNotes": "вирізати поточну нотатку (або поточний вибір) у буфер обміну (використовується для переміщення нотаток)",
|
||||
"pasteNotes": "вставити нотатку(и) як піднотатку в активну нотатку (яка або переміщується, або клонується залежно від того, чи була вона скопійована, чи вирізана в буфер обміну)",
|
||||
"deleteNotes": "видалити нотатку / піддерево",
|
||||
"editingNotes": "Редагування нотаток",
|
||||
"editNoteTitle": "на панелі дерева перемкнеться з панелі дерева на заголовок нотатки. Введення з заголовку нотатки перемкне фокус на текстовий редактор. <kbd>Ctrl+.</kbd> перемкнеться назад з редактора на панель дерева.",
|
||||
"createEditLink": "створити / редагувати зовнішнє посилання",
|
||||
"createInternalLink": "створити внутрішнє посилання",
|
||||
"followLink": "перейти за посиланням під курсором",
|
||||
"insertDateTime": "вставити поточну дату та час у позицію курсору",
|
||||
"jumpToTreePane": "перейти до панелі дерева та прокрутити до активної нотатки",
|
||||
"markdownAutoformat": "Автоформатування, подібне до Markdown",
|
||||
"headings": "<code>##</code>, <code>###</code>, <code>####</code> тощо, а потім пробіл для заголовків",
|
||||
"bulletList": "<code>*</code> або <code>-</code> з пробілом для маркованого списку",
|
||||
"numberedList": "<code>1.</code> або <code>1)</code>, а потім пробіл для нумерованого списку",
|
||||
"blockQuote": "починайте рядок з <code>></code>, а потім пробіл для цитування блоку",
|
||||
"troubleshooting": "Усунення несправностей",
|
||||
"reloadFrontend": "перезавантажити інтерфейс Trilium",
|
||||
"showDevTools": "показати інструменти розробника",
|
||||
"showSQLConsole": "показати консоль SQL",
|
||||
"other": "Інше",
|
||||
"quickSearch": "фокус на швидкому введенні пошуку",
|
||||
"inPageSearch": "пошук на сторінці"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Імпортувати в нотатку",
|
||||
"chooseImportFile": "Вибрати файл імпорту",
|
||||
"importDescription": "Вміст вибраного(их) файлу(ів) буде імпортовано як дочірню(і) нотатку(и) до",
|
||||
"options": "Параметри",
|
||||
"safeImportTooltip": "Експортовані файли Trilium <code>.zip</code> можуть містити виконувані скрипти, які можуть мати шкідливу поведінку. Безпечний імпорт деактивує автоматичне виконання всіх імпортованих скриптів. Зніміть позначку \"Безпечний імпорт\", лише якщо імпортований архів має містити виконувані скрипти, і ви повністю довіряєте вмісту файлу імпорту.",
|
||||
"safeImport": "Безпечний імпорт",
|
||||
"explodeArchivesTooltip": "Якщо цей прапорець позначено, Trilium читатиме файли <code>.zip</code>, <code>.enex</code> та <code>.opml</code> і створюватиме нотатки з файлів усередині цих архівів. Якщо прапорець знято, Trilium додаватиме самі архіви до нотатки.",
|
||||
"explodeArchives": "Зчитати вміст архівів <code>.zip</code>, <code>.enex</code> та <code>.opml</code>.",
|
||||
"shrinkImagesTooltip": "<p>Якщо ви позначите цей параметр, Trilium спробує зменшити імпортовані зображення шляхом масштабування та оптимізації, що може вплинути на сприйняту якість зображення. Якщо не позначити, зображення будуть імпортовані без змін.</p><p>Це не стосується імпорту <code>.zip</code> з метаданими, оскільки передбачається, що ці файли вже оптимізовані.</p>",
|
||||
"shrinkImages": "Зменшити зображення",
|
||||
"textImportedAsText": "Імпортувати HTML, Markdown та TXT як текстові нотатки, якщо це незрозуміло з метаданих",
|
||||
"codeImportedAsCode": "Імпортувати розпізнані файли коду (наприклад, <code>.json</code>) як нотатки з кодом, якщо це незрозуміло з метаданих",
|
||||
"replaceUnderscoresWithSpaces": "Замінити підкреслення пробілами в назвах імпортованих нотаток",
|
||||
"import": "Імпорт",
|
||||
"failed": "Помилка імпорту: {{message}}.",
|
||||
"html_import_tags": {
|
||||
"title": "Теги імпорту HTML",
|
||||
"description": "Налаштуйте, які теги HTML слід зберігати під час імпорту нотаток. Теги, яких немає в цьому списку, будуть видалені під час імпорту. Деякі теги (наприклад, 'script') завжди видаляються з міркувань безпеки.",
|
||||
"placeholder": "Введіть теги HTML, по одному на рядок",
|
||||
"reset_button": "Скинути до Список за замовчуванням"
|
||||
},
|
||||
"import-status": "Статус імпорту",
|
||||
"in-progress": "Триває імпорт: {{progress}}",
|
||||
"successful": "Імпорт успішно завершено."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Підказка",
|
||||
"ok": "ОК",
|
||||
"defaultTitle": "Підказка"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"modal_title": "Захищений сеанс",
|
||||
"help_title": "Довідка щодо захищених нотаток",
|
||||
"close_label": "Закрити",
|
||||
"form_label": "Щоб продовжити запитувану дію, вам потрібно розпочати захищений сеанс, ввівши пароль:",
|
||||
"start_button": "Розпочати захищений сеанс"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Останні зміни",
|
||||
"erase_notes_button": "Стерти видалені нотатки зараз",
|
||||
"deleted_notes_message": "Видалені нотатки стерто.",
|
||||
"no_changes_message": "Поки що жодних змін...",
|
||||
"undelete_link": "відновити",
|
||||
"confirm_undelete": "Ви хочете відновити цю нотатку та її піднотатки?"
|
||||
},
|
||||
"revisions": {
|
||||
"note_revisions": "Версії нотаток",
|
||||
"delete_all_revisions": "Видалити всі версії цієї нотатки",
|
||||
"delete_all_button": "Видалити всі версії",
|
||||
"help_title": "Довідка щодо версій нотаток",
|
||||
"revision_last_edited": "Цю версію востаннє редагували {{date}}",
|
||||
"confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?",
|
||||
"no_revisions": "Поки що немає версій цієї нотатки...",
|
||||
"restore_button": "Відновити",
|
||||
"confirm_restore": "Ви хочете відновити цю версію? Це замінить поточний заголовок та вміст нотатки цієї версії.",
|
||||
"delete_button": "Видалити",
|
||||
"confirm_delete": "Ви хочете видалити цю версію?",
|
||||
"revisions_deleted": "Версії нотаток видалено.",
|
||||
"revision_restored": "Версію нотатки відновлено.",
|
||||
"revision_deleted": "Версію нотатки видалено.",
|
||||
"snapshot_interval": "Інтервал знімків версій нотатки: {{seconds}}s.",
|
||||
"maximum_revisions": "Ліміт знімків версій нотатки: {{number}}.",
|
||||
"settings": "Налаштування версій нотатки",
|
||||
"download_button": "Завантажити",
|
||||
"mime": "МІМЕ: ",
|
||||
"file_size": "Розмір файлу:",
|
||||
"preview": "Попередній перегляд:",
|
||||
"preview_not_available": "Попередній перегляд недоступний для цього типу нотатки."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Включити нотатку",
|
||||
"label_note": "Нотатка",
|
||||
"placeholder_search": "пошук нотатки за її назвою",
|
||||
"box_size_prompt": "Розмір вмісту з вкладеною нотаткою:",
|
||||
"box_size_small": "маленький (~ 10 рядків)",
|
||||
"box_size_medium": "середній (~ 30 рядків)",
|
||||
"box_size_full": "повний (вміст показує повний текст)",
|
||||
"button_include": "Включити Нотатку"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Інформаційне повідомлення",
|
||||
"closeButton": "Закрити",
|
||||
"okButton": "ОК"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Пошук нотатки за її назвою або типом > для команд...",
|
||||
"search_button": "Повнотекстовий Пошук"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Імпорт з Markdown",
|
||||
"modal_body_text": "Через \"пісочницю\" браузера неможливо безпосередньо зчитувати буфер обміну з JavaScript. Будь ласка, вставте код Markdown для імпорту в текстове поле нижче та натисніть кнопку \"Імпортувати\"",
|
||||
"import_button": "Імпорт",
|
||||
"import_success": "Вміст Markdown імпортовано в документ."
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Перемістити нотатки до ...",
|
||||
"notes_to_move": "Нотатки для переміщення",
|
||||
"search_placeholder": "пошук нотатки за її назвою",
|
||||
"move_button": "Перейти до вибраної нотатки",
|
||||
"error_no_path": "Немає шляху для переміщення.",
|
||||
"move_success_message": "Вибрані нотатки переміщено до ",
|
||||
"target_parent_note": "Цільова батьківська нотатка"
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"change_path_prompt": "Змінити місце створення нової нотатки:",
|
||||
"search_placeholder": "пошук шляху за назвою (за замовчуванням, якщо порожня)",
|
||||
"modal_title": "Вибрати тип нотатки",
|
||||
"modal_body": "Вибрати тип/шаблон нової нотатки:",
|
||||
"templates": "Шаблони",
|
||||
"builtin_templates": "Вбудовані Шаблони"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "Пароль не встановлено",
|
||||
"body1": "Захищені нотатки шифруються за допомогою пароля користувача, але пароль ще не встановлено.",
|
||||
"body2": "Щоб захистити нотатки, натисніть кнопку нижче, щоб відкрити діалогове вікно Параметри та встановити пароль.",
|
||||
"go_to_password_options": "Перейти до параметрів пароля"
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "Сортування дочірніх за...",
|
||||
"sorting_criteria": "Критерії сортування",
|
||||
"title": "заголовок",
|
||||
"date_created": "дата створення",
|
||||
"date_modified": "дата зміни",
|
||||
"sorting_direction": "Напрямок сортування",
|
||||
"ascending": "зростання",
|
||||
"descending": "спадний",
|
||||
"folders": "Папки",
|
||||
"sort_folders_at_top": "сортувати папки зверху",
|
||||
"natural_sort": "Нативне сортування",
|
||||
"sort_with_respect_to_different_character_sorting": "сортувати з урахуванням різних правил сортування та порівняння символів у різних мовах або регіонах.",
|
||||
"natural_sort_language": "Мова нативного сортування",
|
||||
"the_language_code_for_natural_sort": "Код мови для нативного сортування, наприклад, \"zh-CN\" для китайської.",
|
||||
"sort": "Сортування"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Завантажити вкладення до нотатки",
|
||||
"choose_files": "Вибрати файли",
|
||||
"files_will_be_uploaded": "Файли будуть завантажені як вкладення до {{noteTitle}}",
|
||||
"options": "Параметри",
|
||||
"shrink_images": "Зменшити зображення",
|
||||
"upload": "Скачати",
|
||||
"tooltip": "Якщо ви позначите цей параметр, Trilium спробує зменшити розмір завантажених зображень шляхом масштабування та оптимізації, що може вплинути на сприйняту якість зображення. Якщо вимкнути цей параметр, зображення будуть завантажені без змін."
|
||||
},
|
||||
"attribute_detail": {
|
||||
"attr_detail_title": "Деталі Атрибуту Заголовок",
|
||||
"close_button_title": "Скасувати зміни та закрити",
|
||||
"attr_is_owned_by": "Атрибут належить",
|
||||
"attr_name_title": "Ім'я атрибута може складатися лише з буквено-цифрових символів, двокрапки та символу підкреслення",
|
||||
"name": "Назва",
|
||||
"value": "Значення",
|
||||
"target_note_title": "Зв'язок — це іменований зв'язок між джерелом та цільовою нотаткою.",
|
||||
"target_note": "Цільова нотатка",
|
||||
"promoted_title": "Просунутий атрибут відображається на нотатці у помітному вигляді.",
|
||||
"promoted": "Просунуті",
|
||||
"promoted_alias_title": "Назва, яка відображатиметься в інтерфейсі просунутих атрибутів.",
|
||||
"promoted_alias": "Псевдонім",
|
||||
"multiplicity_title": "Множинність визначає, скільки атрибутів з однаковою назвою можна створити — максимум 1 або більше 1.",
|
||||
"multiplicity": "Множинність",
|
||||
"single_value": "Одне значення",
|
||||
"multi_value": "Декілька значень",
|
||||
"label_type_title": "Тип мітки допоможе Trilium вибрати відповідний інтерфейс для введення значення мітки.",
|
||||
"label_type": "Тип",
|
||||
"text": "Текст",
|
||||
"number": "Номер",
|
||||
"boolean": "Булева",
|
||||
"date": "Дата",
|
||||
"date_time": "Дата & Час",
|
||||
"time": "Час",
|
||||
"url": "URL",
|
||||
"precision_title": "Яка кількість цифр після числа з плаваючою комою має бути доступна в інтерфейсі налаштування значень.",
|
||||
"precision": "Точність",
|
||||
"digits": "цифри",
|
||||
"inverse_relation_title": "Додаткове налаштування для визначення, який зв'язок є зворотнім цьому зв'язку. Приклад: Батьківська - Дочірня інверсним зв'язком одне до одного.",
|
||||
"inverse_relation": "Інверсний зв'язок",
|
||||
"inheritable_title": "Спадковий атрибут буде успадкований усіма нащадками в цьому дереві.",
|
||||
"inheritable": "Спадковий",
|
||||
"save_and_close": "Зберегти & закрити <kbd>Ctrl+Enter</kbd>",
|
||||
"delete": "Видалити",
|
||||
"related_notes_title": "Інші нотатки з цією міткою",
|
||||
"more_notes": "Більше нотаток",
|
||||
"label": "Деталі Мітки",
|
||||
"label_definition": "Деталі визначення мітки",
|
||||
"relation": "Деталі зв'язку",
|
||||
"relation_definition": "Деталі визначення зв'язку",
|
||||
"disable_versioning": "вимикає автоматичне керування версіями. Корисно, наприклад, для великих, але неважливих нотаток, наприклад, великих JS-бібліотек, що використовуються для написання скриптів",
|
||||
"calendar_root": "позначити нотатку, яка буде використовуватись для щоденника за замовчуванням. Тільки одна може бути такою.",
|
||||
"archived": "нотатки з цією міткою не будуть видимими за замовчуванням у результатах пошуку (також у діалогових вікнах Перейти до..., Додати посилання... тощо).",
|
||||
"exclude_from_export": "нотатки (з їхнім піддеревом) не будуть включені до жодного експорту нотаток",
|
||||
"run": "визначає, за яких подій має запускатися скрипт. Можливі значення:\n<ul>\n<li>frontendStartup – коли запускається (або оновлюється) інтерфейс Trilium, але не на мобільному пристрої.</li>\n<li>mobileStartup – коли запускається (або оновлюється) інтерфейс Trilium на мобільному пристрої.</li>\n<li>backendStartup – коли запускається бекенд Trilium</li>\n<li>hourly – запускається раз на годину. Ви можете використовувати додаткову мітку <code>runAtHour</code>, щоб вказати, о котрій годині.</li>\n<li>daily – запускається раз на день</li>\n</ul>",
|
||||
"run_on_instance": "Визначити, на якому екземплярі Trilium це має бути запущено. За замовчуванням використовувати всі екземпляри.",
|
||||
"run_at_hour": "О котрій годині це має запускатися? Слід використовувати разом з <code>#run=hourly</code>. Можна визначити кілька разів для більшої кількості запусків протягом дня.",
|
||||
"disable_inclusion": "скрипти з цією міткою не будуть включені до виконання базового скрипта.",
|
||||
"sorted": "зберігає дочірні нотатки, відсортовані за заголовком в алфавітному порядку",
|
||||
"sort_direction": "ASC (за замовчуванням) або DESC",
|
||||
"sort_folders_first": "Папки (нотатки з дочірніми) слід сортувати зверху",
|
||||
"top": "зберегти задану нотатку зверху в батьківській (застосовується лише до відсортованих батьківських)",
|
||||
"hide_promoted_attributes": "Сховати просунуті атрибути для цієї нотатки",
|
||||
"read_only": "редактор перебуває в режимі лише для читання. Працює лише для тексту та нотаток з кодом.",
|
||||
"auto_read_only_disabled": "текстові/кодові нотатки можна автоматично перевести в режим читання, якщо вони занадто великі. Ви можете вимкнути цю поведінку для кожної окремої нотатки, додавши до неї цю позначку",
|
||||
"app_css": "позначає CSS-нотатки, які завантажуються в програму Trilium і, таким чином, можуть бути використані для зміни зовнішнього вигляду Trilium.",
|
||||
"app_theme": "позначає CSS-нотатки, які є повноцінними темами Trilium і тому доступні в параметрах Trilium.",
|
||||
"app_theme_base": "встановіть значення \"наступна\", \"наступна-світла\" або \"наступна-темна\", щоб використовувати відповідну тему TriliumNext (автоматичну, світлу або темну) як основу для власної теми, замість застарілої.",
|
||||
"css_class": "значення цієї мітки потім додається як CSS-клас до вузла, що представляє задану нотатку в дереві. Це може бути корисним для розширеного налаштування тем. Можна використовувати в шаблонах нотаток.",
|
||||
"icon_class": "значення цієї мітки додається як CSS-клас до значка на дереві, що може допомогти візуально розрізнити нотатки в дереві. Прикладом може бути bx bx-home - значки взяті з boxicons. Можна використовувати в шаблонах нотаток.",
|
||||
"page_size": "кількість елементів на сторінці у списку нотаток",
|
||||
"custom_request_handler": "див. <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Спеціальний обробник запитів</a>",
|
||||
"custom_resource_provider": "див. <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Спеціальний обробник запитів</a>",
|
||||
"widget": "позначає цю нотатку як користувацький віджет, який буде додано до дерева компонентів Trilium",
|
||||
"workspace": "позначає цю нотатку як робочу область, що дозволяє легко хостити",
|
||||
"workspace_icon_class": "визначає значка CSS-класу, який буде використовуватися у вкладці при хостингу цієї нотатки",
|
||||
"workspace_tab_background_color": "Колір CSS, що використовується у вкладці нотатки під час перенесення до цієї нотатки",
|
||||
"workspace_calendar_root": "Визначає календар для кожного робочого простору",
|
||||
"workspace_template": "Ця нотатка з'явиться у списку доступних шаблонів під час створення нової нотатки, але лише після перенесення її в робочу область, що містить цей шаблон",
|
||||
"search_home": "нові пошукові нотатки будуть створені як дочірні елементи цієї нотатки",
|
||||
"workspace_search_home": "нові пошукові нотатки будуть створені як дочірні елементи цієї нотатки після хостингу до якогось предка цієї нотатки робочої області",
|
||||
"inbox": "розташування Вхідних за замовчуванням для нових нотаток – під час створення нотатки за допомогою кнопки Нова нотатка на бічній панелі, нотатки будуть створені як дочірні нотатки в нотатці з міткою <code>#inbox</code>.",
|
||||
"workspace_inbox": "розташування Вхідні за замовчуванням для нових нотаток, коли вони переносяться до якоїсь батьківської нотатки в робочій області",
|
||||
"sql_console_home": "розташування нотаток консолі SQL за замовчуванням",
|
||||
"bookmark_folder": "нотатка з цією міткою відображатиметься в закладках як папка (що дозволить доступ до її дочірніх елементів)",
|
||||
"share_hidden_from_tree": "ця нотатка прихована в лівому дереві навігації, але все ще доступна за її URL-адресою",
|
||||
"share_external_link": "нотатка діятиме як посилання на зовнішній вебсайт у дереві спільного доступу",
|
||||
"share_alias": "визначає псевдонім, за допомогою якого нотатка буде доступна за адресою https://your_trilium_host/share/[your_alias]",
|
||||
"share_omit_default_css": "CSS сторінки спільного доступу за замовчуванням буде пропущено. Використовуйте, коли ви вносите значні зміни стилю.",
|
||||
"share_description": "визначити текст, який буде додано до метатегу HTML для опису",
|
||||
"share_raw": "нотатка буде надаватися у необробленому форматі, без HTML-оболонки",
|
||||
"share_disallow_robot_indexing": "заборонити індексацію цієї нотатки роботами через заголовок <code>X-Robots-Tag: noindex</code>",
|
||||
"share_credentials": "потрібні облікові дані для доступу до цієї спільної нотатки. Значення повинно бути у форматі «ім'я користувача:пароль». Не забудьте зробити це спадковим, щоб застосувати до дочірніх нотаток/зображень.",
|
||||
"share_index": "нотатка з цією міткою відобразить список усіх коренів спільних нотаток",
|
||||
"display_relations": "назви зв'язків, розділені комами, які слід відображати. Усі інші будуть приховані.",
|
||||
"hide_relations": "назви зв'язків, розділені комами, які слід приховати. Усі інші будуть відображені.",
|
||||
"title_template": "заголовок нотаток за замовчуванням, створених як дочірні елементи цієї нотатки. Значення оцінюється як рядок JavaScript\nі таким чином може бути збагачене динамічним контентом за допомогою вставлених змінних <code>now</code> та <code>parentNote</code>. Приклади:\n\n<ul>\nЛітературні твори <li><code>${parentNote.getLabelValue('authorName')}</code></li>\n<li><code>Журнал для ${now.format('РРРР-ММ-ДД ГГ:мм:сс')</code></li>\n</ul>\n\nДив. <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">вікі з деталями</a>, документацію API для <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> та <a href=\"https://day.js.org/docs/en/display/format\">now</a> для отримання детальної інформації.",
|
||||
"template": "Ця нотатка з'явиться у списку доступних шаблонів під час створення нової нотатки",
|
||||
"toc": "<code>#toc</code> або <code>#toc=show</code> примусово покаже Зміст, <code>#toc=hide</code> примусово приховає його. Якщо мітка не існує, дотримується глобального налаштування",
|
||||
"color": "визначає колір нотатки в дереві нотаток, посиланнях тощо. Використовуйте будь-яке дійсне значення кольору CSS, наприклад, 'red' або #a13d5f",
|
||||
"keyboard_shortcut": "Визначає комбінацію клавіш, щоб негайно перейти до цієї нотатки. Приклад: «ctrl+alt+e». Щоб зміни набули чинності, потрібне перезавантаження інтерфейсу.",
|
||||
"keep_current_hoisting": "Відкриття цього посилання не змінить хостинг, навіть якщо нотатка не відображається в поточному піддереві хостінгу.",
|
||||
"execute_button": "Назва кнопки, яка виконає поточну нотатку з кодом",
|
||||
"execute_description": "Більш детальний опис поточного коду, що відображається разом із кнопкою виконання",
|
||||
"exclude_from_note_map": "Нотатки з цією міткою будуть приховані на Карті Нотатки",
|
||||
"new_notes_on_top": "Нові нотатки будуть створені зверху батьківської нотатки, а не знизу.",
|
||||
"hide_highlight_widget": "Приховати віджет Списку виділення",
|
||||
"run_on_note_creation": "виконується, коли нотатка створюється на серверній частині. Використовуйте цей зв'язок, якщо потрібно запустити скрипт для всіх нотаток, створених у певному піддереві. У такому випадку створіть його на батьківській нотатці піддерева та зробіть його успадковуваним. Нова нотатка, створена в піддереві (будь-якої глибини), запустить скрипт.",
|
||||
"run_on_child_note_creation": "виконується, коли створюється нова нотатка під нотаткою, де визначено цей зв'язок",
|
||||
"run_on_note_title_change": "виконується, коли змінюється заголовок нотатки (включає також створення нотатки)",
|
||||
"run_on_note_content_change": "виконується, коли змінюється вміст нотатки (включаючи також створення нотатки).",
|
||||
"run_on_note_change": "виконується, коли нотатку змінено (включає також створення нотатки). Не включає зміни вмісту",
|
||||
"run_on_note_deletion": "виконується під час видалення нотатки",
|
||||
"run_on_branch_creation": "виконується під час створення гілки. Гілка — це зв'язок між батьківською та дочірньою нотаткою та створюється, наприклад, під час клонування або переміщення нотатки.",
|
||||
"run_on_branch_change": "виконується, коли гілка оновлюється.",
|
||||
"run_on_branch_deletion": "виконується, коли гілку видаляють. Гілка — це зв'язок між батьківською та дочірньою нотатками та видаляється, наприклад, під час переміщення нотатки (стара гілка/посилання видаляється).",
|
||||
"share_root": "позначає нотатку, яка подається на кореневому каталозі /share.",
|
||||
"run_on_attribute_creation": "виконується, коли для нотатки створюється новий атрибут, який визначає цей зв'язок",
|
||||
"run_on_attribute_change": " виконується, коли змінюється атрибут нотатки, яка визначає цей зв'язок. Це також спрацьовує, коли атрибут видаляється",
|
||||
"relation_template": "атрибути нотатки будуть успадковані навіть без зв'язку \"батьківський-дочірній\", вміст нотатки та піддерево будуть додані до екземпляра нотатки, якщо він порожній. Див. документацію для отримання детальної інформації.",
|
||||
"inherit": "атрибути нотатки будуть успадковані навіть без зв'язку «батьківський-дочірній». Див. шаблон зв'язку для подібної концепції. Див. успадкування атрибутів у документації.",
|
||||
"render_note": "нотатки типу \"render HTML note\" будуть відображатися за допомогою нотатки з кодом (HTML або скрипта), і необхідно вказати за допомогою цього зв'язку, яку нотатку слід відображати",
|
||||
"widget_relation": "ціль цього зв'язку буде виконано та відображено як віджет на бічній панелі",
|
||||
"share_css": "CSS-нотатка, яка буде вставлена на сторінку спільного доступу. CSS-нотатка також має бути в спільному піддереві. Також розгляньте можливість використання 'share_hidden_from_tree' та 'share_omit_default_css'.",
|
||||
"share_js": "JavaScript-нотатка, яка буде вставлена на сторінку спільного доступу. JS-нотатка також має бути в спільному піддереві. Розгляньте можливість використання 'share_hidden_from_tree'.",
|
||||
"share_template": "Вбудована нотатка JavaScript, яка використовуватиметься як шаблон для відображення спільної нотатки. Повертатиметься до шаблону за замовчуванням. Розгляньте можливість використання 'share_hidden_from_tree'.",
|
||||
"share_favicon": "Нотатку до значка веб-сторінки, яку потрібно встановити на спільній сторінці. Зазвичай потрібно встановити її як спільний кореневий каталог і зробити успадковуваною. Нотатку до значка веб-сторінки також потрібно розмістити у спільному піддереві. Розгляньте можливість використання 'share_hidden_from_tree'.",
|
||||
"is_owned_by_note": "належить до нотатки",
|
||||
"other_notes_with_name": "Інші нотатки з назвою {{attributeType}} \"{{attributeName}}\"",
|
||||
"and_more": "... та ще {{count}}.",
|
||||
"print_landscape": "Під час експорту в PDF змінює орієнтацію сторінки на альбомну замість портретної.",
|
||||
"print_page_size": "Під час експорту в PDF змінює розмір сторінки. Підтримувані значення: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
|
||||
"color_type": "Колір"
|
||||
},
|
||||
"multi_factor_authentication": {
|
||||
"mfa_method": "Метод МФА"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "Щоб додати мітку, просто введіть, наприклад, <code>#rock</code> або, якщо ви хочете додати також значення, то, наприклад, <code>#year = 2020</code>",
|
||||
"help_text_body2": "Для зв'язку введіть <code>~author = @</code>, що має викликати автозаповнення, де ви зможете знайти потрібну нотатку.",
|
||||
"help_text_body3": "Або ж ви можете додати мітку та зв'язок за допомогою кнопки <code>+</code> праворуч.",
|
||||
"save_attributes": "Зберегти атрибути <enter>",
|
||||
"add_a_new_attribute": "Додати новий атрибут",
|
||||
"add_new_label": "Додати нову мітку <kbd data-command=\"addNewLabel\"></kbd>",
|
||||
"add_new_relation": "Додати новий зв'язок <kbd data-command=\"addNewRelation\"></kbd>",
|
||||
"add_new_label_definition": "Додати нове визначення мітки",
|
||||
"add_new_relation_definition": "Додати нове визначення зв'язку",
|
||||
"placeholder": "Введіть мітки та зв'язки тут"
|
||||
},
|
||||
"abstract_bulk_action": {
|
||||
"remove_this_search_action": "Видалити цю дію пошуку"
|
||||
},
|
||||
"execute_script": {
|
||||
"execute_script": "Виконати скрипт",
|
||||
"help_text": "Ви можете виконувати прості скрипти у нотатках, що збігаються.",
|
||||
"example_1": "Наприклад, щоб додати рядок до заголовка нотатки, використовуйте цей невеликий скрипт:",
|
||||
"example_2": "Більш складним прикладом може бути видалення всіх атрибутів нотаток, що збігаються:"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Додати мітку",
|
||||
"label_name_placeholder": "назва мітки",
|
||||
"label_name_title": "Дозволено використовувати буквено-цифрові символи, підкреслення та двокрапку.",
|
||||
"to_value": "до значення",
|
||||
"new_value_placeholder": "нове значення",
|
||||
"help_text": "У всіх нотатках, що збігаються:",
|
||||
"help_text_item1": "створити вказану мітку, якщо нотатка ще її не має",
|
||||
"help_text_item2": "або змінити значення існуючої мітки",
|
||||
"help_text_note": "Ви також можете викликати цей метод без значення, у такому випадку мітку буде присвоєно нотатці без значення."
|
||||
},
|
||||
"delete_label": {
|
||||
"delete_label": "Видалити мітку",
|
||||
"label_name_placeholder": "назва мітки",
|
||||
"label_name_title": "Дозволено використовувати буквено-цифрові символи, підкреслення та двокрапку."
|
||||
},
|
||||
"rename_label": {
|
||||
"rename_label": "Перейменувати мітку",
|
||||
"rename_label_from": "Перейменувати мітку з",
|
||||
"old_name_placeholder": "стара назва",
|
||||
"to": "До",
|
||||
"new_name_placeholder": "нова назва",
|
||||
"name_title": "Дозволено використовувати буквено-цифрові символи, підкреслення та двокрапку."
|
||||
},
|
||||
"update_label_value": {
|
||||
"update_label_value": "Оновити значення мітки",
|
||||
"label_name_placeholder": "назва мітки",
|
||||
"label_name_title": "Дозволено використовувати буквено-цифрові символи, підкреслення та двокрапку.",
|
||||
"to_value": "до значення",
|
||||
"new_value_placeholder": "нове значення",
|
||||
"help_text": "Для всіх нотаток, що збігаються, змініть значення існуючої мітки.",
|
||||
"help_text_note": "Ви також можете викликати цей метод без значення, у такому випадку мітку буде присвоєно нотатці без значення."
|
||||
},
|
||||
"delete_note": {
|
||||
"delete_note": "Видалити нотатку",
|
||||
"delete_matched_notes": "Видалити нотатки, що збігаються",
|
||||
"delete_matched_notes_description": "Це видалить нотатки, що збігаються.",
|
||||
"undelete_notes_instruction": "Після видалення їх можна відновити в діалоговому вікні «Останні зміни».",
|
||||
"erase_notes_instruction": "Щоб остаточно видалити нотатки, після видалення перейдіть до меню Опції -> Інше та натисніть кнопку «Стерти видалені нотатки зараз»."
|
||||
},
|
||||
"delete_revisions": {
|
||||
"all_past_note_revisions": "Усі попередні версії нотаток, що збігаються, будуть видалені. Сама нотатка буде повністю збережена. Іншими словами, історія нотатки буде видалена.",
|
||||
"delete_note_revisions": "Видалити версії нотаток"
|
||||
},
|
||||
"move_note": {
|
||||
"on_all_matched_notes": "У всіх нотатках, що збігаються",
|
||||
"move_note": "Перемістити нотатку",
|
||||
"to": "до",
|
||||
"target_parent_note": "цільова батьківська нотатка",
|
||||
"move_note_new_parent": "перемістити нотатку до нового батьківського елемента, якщо нотатка має лише один батьківський елемент (тобто стара гілка видаляється, а нова гілка в новому батьківському елементі створюється)",
|
||||
"clone_note_new_parent": "клонувати нотатку до нового батьківського елемента, якщо нотатка має кілька клонів/гілок (незрозуміло, яку гілку слід видалити)",
|
||||
"nothing_will_happen": "нічого не станеться, якщо нотатку не можна перемістити до цільової нотатки (тобто це створить деревоподібний цикл)"
|
||||
},
|
||||
"rename_note": {
|
||||
"example_note": "<code>Нотатка</code> – усі нотатки, що збігаються будуть перейменовані на \"Нотатка\"",
|
||||
"example_new_title": "<code>NEW: ${note.title}</code> – назви нотаток, що збігаються, мають префікс 'НОВА:'",
|
||||
"example_date_prefix": "<code>${note.dateCreatedObj.format('MM-DD:')}: ${note. Title}</code> - нотатки, що збігаються мають префікс у вигляді місяця та дати створення нотатки",
|
||||
"rename_note": "Перейменувати нотатку",
|
||||
"rename_note_title_to": "Перейменувати заголовок нотатки на",
|
||||
"new_note_title": "новий заголовок нотатки",
|
||||
"click_help_icon": "Натисніть значок довідки праворуч, щоб переглянути всі опції",
|
||||
"evaluated_as_js_string": "Надане значення обчислюється як рядок JavaScript і тому може бути доповнено динамічним контентом за допомогою вставленої змінної <code>note</code> (нотатка перейменовується). Приклади:",
|
||||
"api_docs": "Див. документацію API для <a href='https://zadam.github.io/trilium/backend_api/Note.html'>note</a> та її <a href='https://day.js.org/docs/en/display/format'>властивостей dateCreatedObj / utcDateCreatedObj</a> для отримання детальної інформації."
|
||||
},
|
||||
"add_relation": {
|
||||
"create_relation_on_all_matched_notes": "Для всіх нотаток, що збігаються, створити задані зв'язки.",
|
||||
"add_relation": "Додати зв'язок",
|
||||
"relation_name": "назва зв'язку",
|
||||
"allowed_characters": "Дозволено використовувати буквено-цифрові символи, підкреслення та двокрапку.",
|
||||
"to": "до",
|
||||
"target_note": "цільова нотатка"
|
||||
},
|
||||
"update_relation_target": {
|
||||
"on_all_matched_notes": "У всіх нотатках, що збігаються",
|
||||
"update_relation": "Оновити зв'язок",
|
||||
"relation_name": "назва зв'язку",
|
||||
"allowed_characters": "Дозволено використовувати буквено-цифрові символи, підкреслення та двокрапку.",
|
||||
"to": "до",
|
||||
"target_note": "цільова нотатка",
|
||||
"change_target_note": "змінити цільову нотатку існуючого зв'язку",
|
||||
"update_relation_target": "Оновити ціль зв'язку"
|
||||
},
|
||||
"search_script": {
|
||||
"example_code": "// 1. попередня фільтрація за допомогою стандартного пошуку\nconst candidateNotes = api.searchForNotes(\"#journal\");\n\n// 2. застосування користувацьких критеріїв пошуку\nconst matchedNotes = candidateNotes\n .filter(note => note.title.match(/[0-9]{1,2}\\. ?[0-9]{1,2}\\. ?[0-9]{4}/));\n\nreturn matchedNotes;"
|
||||
},
|
||||
"delete_relation": {
|
||||
"delete_relation": "Видалити зв'язок",
|
||||
"relation_name": "назва зв'язку",
|
||||
"allowed_characters": "Дозволено використовувати буквено-цифрові символи, підкреслення та двокрапку."
|
||||
},
|
||||
"rename_relation": {
|
||||
"rename_relation": "Перейменувати зв'язок",
|
||||
"rename_relation_from": "Перейменувати зв'язок з",
|
||||
"old_name": "стара назва",
|
||||
"to": "До",
|
||||
"new_name": "нова назва",
|
||||
"allowed_characters": "Дозволено використовувати буквено-цифрові символи, підкреслення та двокрапку."
|
||||
},
|
||||
"attachments_actions": {
|
||||
"open_externally": "Відкрити у зовнішній програмі",
|
||||
"open_externally_title": "Файл буде відкрито в зовнішній програмі та відстежуватися на наявність змін. Після цього ви зможете завантажити змінену версію назад до Trilium.",
|
||||
"open_custom": "Відкрити користувацький",
|
||||
"open_custom_title": "Файл буде відкрито в зовнішній програмі та відстежуватися на наявність змін. Після цього ви зможете завантажити змінену версію назад до Trilium.",
|
||||
"download": "Завантажити",
|
||||
"rename_attachment": "Перейменувати вкладення",
|
||||
"upload_new_revision": "Завантажити нову версію",
|
||||
"copy_link_to_clipboard": "Копіювати посилання в буфер обміну",
|
||||
"convert_attachment_into_note": "Перетворити вкладення на нотатку",
|
||||
"delete_attachment": "Видалити вкладення",
|
||||
"upload_success": "Нову версію вкладеного файлу завантажено.",
|
||||
"upload_failed": "Не вдалося завантажити нову версію вкладеного файлу.",
|
||||
"open_externally_detail_page": "Відкриття вкладення ззовні доступне лише зі сторінки з деталями, спочатку натисніть на деталі вкладення та повторіть дію.",
|
||||
"open_custom_client_only": "Налаштування відкриття вкладень можна виконати лише з клієнтської версії для ПК.",
|
||||
"delete_confirm": "Ви впевнені, що хочете видалити вкладення '{{title}}'?",
|
||||
"delete_success": "Вкладення '{{title}}' видалено.",
|
||||
"convert_confirm": "Ви впевнені, що хочете перетворити вкладення '{{title}}' на окрему нотатку?",
|
||||
"convert_success": "Вкладення '{{title}}' перетворено на нотатку.",
|
||||
"enter_new_name": "Будь ласка, введіть назву нового вкладення"
|
||||
},
|
||||
"calendar": {
|
||||
"mon": "Пн",
|
||||
"tue": "Вт",
|
||||
"wed": "Ср",
|
||||
"thu": "Чт",
|
||||
"fri": "Пт",
|
||||
"sat": "Сб",
|
||||
"sun": "Нд",
|
||||
"cannot_find_day_note": "Не вдається знайти денну нотатку",
|
||||
"cannot_find_week_note": "Не вдається знайти нотатку тижня",
|
||||
"january": "Січень",
|
||||
"febuary": "Лютий",
|
||||
"march": "Березень",
|
||||
"april": "Квітень",
|
||||
"may": "Травень",
|
||||
"june": "Червень",
|
||||
"july": "Липень",
|
||||
"august": "Серпень",
|
||||
"september": "Вересень",
|
||||
"october": "Жовтень",
|
||||
"november": "Листопад",
|
||||
"december": "Грудень"
|
||||
},
|
||||
"close_pane_button": {
|
||||
"close_this_pane": "Закрити цю панель"
|
||||
},
|
||||
"create_pane_button": {
|
||||
"create_new_split": "Створити новий поділ"
|
||||
},
|
||||
"edit_button": {
|
||||
"edit_this_note": "Редагувати цю нотатку"
|
||||
},
|
||||
"show_toc_widget_button": {
|
||||
"show_toc": "Показати зміст"
|
||||
},
|
||||
"show_highlights_list_widget_button": {
|
||||
"show_highlights_list": "Показати Список основних моментів"
|
||||
},
|
||||
"zen_mode": {
|
||||
"button_exit": "Вихід з Дзен-режиму"
|
||||
},
|
||||
"sync_status": {
|
||||
"unknown": "<p>Стан синхронізації буде відомий після початку наступної спроби синхронізації.</p><p>Натисніть, щоб запустити синхронізацію зараз.</p>",
|
||||
"connected_with_changes": "<p>Підключено до сервера синхронізації. <br>Є деякі невиконані зміни, які ще потрібно синхронізувати.</p><p>Натисніть, щоб розпочати синхронізацію.</p>",
|
||||
"connected_no_changes": "<p>Підключено до сервера синхронізації.<br>Усі зміни вже синхронізовано.</p><p>Натисніть, щоб розпочати синхронізацію.</p>",
|
||||
"disconnected_with_changes": "<p>Встановлення з’єднання із сервером синхронізації не вдалося.<br>Є деякі невиконані зміни, які ще потрібно синхронізувати.</p><p>Натисніть, щоб розпочати синхронізацію.</p>",
|
||||
"disconnected_no_changes": "<p>Встановлення з’єднання із сервером синхронізації не вдалося.<br>Усі відомі зміни синхронізовано.</p><p>Натисніть, щоб розпочати синхронізацію.</p>",
|
||||
"in_progress": "Триває синхронізація із сервером."
|
||||
},
|
||||
"left_pane_toggle": {
|
||||
"show_panel": "Показати панель",
|
||||
"hide_panel": "Приховати панель"
|
||||
},
|
||||
"move_pane_button": {
|
||||
"move_left": "Переміститися вліво",
|
||||
"move_right": "Переміститися вправо"
|
||||
},
|
||||
"note_actions": {
|
||||
"convert_into_attachment": "Перетворити на вкладення",
|
||||
"re_render_note": "Повторно відобразити нотатку",
|
||||
"search_in_note": "Пошук у нотатці",
|
||||
"note_source": "Джерело нотатки",
|
||||
"note_attachments": "Вкладення нотатки",
|
||||
"open_note_externally": "Відкрити нотатку у зовнішній програмі",
|
||||
"open_note_externally_title": "Файл буде відкрито в зовнішній програмі та відстежуватися на наявність змін. Після цього ви зможете завантажити змінену версію назад до Trilium.",
|
||||
"open_note_custom": "Відкрити нотатку користувача",
|
||||
"import_files": "Імпорт файлів",
|
||||
"export_note": "Експорт нотатки",
|
||||
"delete_note": "Видалити нотатку",
|
||||
"print_note": "Друк нотатки",
|
||||
"save_revision": "Зберегти версію",
|
||||
"convert_into_attachment_failed": "Не вдалося конвертувати нотатку '{{title}}'.",
|
||||
"convert_into_attachment_successful": "Нотатку '{{title}}' перетворено на вкладення.",
|
||||
"convert_into_attachment_prompt": "Ви впевнені, що хочете перетворити нотатку '{{title}}' на вкладення батьківської нотатки?",
|
||||
"print_pdf": "Експортувати як PDF..."
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "Віджет кнопки '{{componentId}}' не має визначеного обробника кліків"
|
||||
},
|
||||
"protected_session_status": {
|
||||
"active": "Захищений сеанс активний. Натисніть, щоб вийти з захищеного сеансу.",
|
||||
"inactive": "Натисніть, щоб увійти до захищеного сеансу"
|
||||
},
|
||||
"revisions_button": {
|
||||
"note_revisions": "Версії нотатки"
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,80 @@
|
||||
{
|
||||
"about": {
|
||||
"homepage": "Trang chủ:",
|
||||
"title": "Về Trilium Notes"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Thêm liên kết",
|
||||
"button_add_link": "Thêm liên kết"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"other": "Khác"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "Lưu"
|
||||
},
|
||||
"confirm": {
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Đóng",
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"export": {
|
||||
"close": "Đóng"
|
||||
},
|
||||
"help": {
|
||||
"other": "Khác"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Lỗi nghiêm trọng"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Đóng"
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Chuyển ghi chép tới..."
|
||||
},
|
||||
"prompt": {
|
||||
"ok": "OK"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Đóng"
|
||||
},
|
||||
"revisions": {
|
||||
"restore_button": "Khôi phục",
|
||||
"delete_button": "Xoá"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"attribute_detail": {
|
||||
"name": "Tên",
|
||||
"value": "Giá trị",
|
||||
"text": "Văn bản",
|
||||
"number": "Số",
|
||||
"delete": "Xoá"
|
||||
},
|
||||
"rename_note": {
|
||||
"rename_note": "Đổi tên ghi chép"
|
||||
"about": {
|
||||
"homepage": "Trang chủ:",
|
||||
"title": "Về Trilium Notes"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Thêm liên kết",
|
||||
"button_add_link": "Thêm liên kết"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"other": "Khác"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"save": "Lưu"
|
||||
},
|
||||
"confirm": {
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Đóng",
|
||||
"ok": "OK",
|
||||
"cancel": "Huỷ"
|
||||
},
|
||||
"export": {
|
||||
"close": "Đóng"
|
||||
},
|
||||
"help": {
|
||||
"other": "Khác"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Lỗi nghiêm trọng"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Đóng"
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Chuyển ghi chép tới..."
|
||||
},
|
||||
"prompt": {
|
||||
"ok": "OK"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Đóng"
|
||||
},
|
||||
"revisions": {
|
||||
"restore_button": "Khôi phục",
|
||||
"delete_button": "Xoá"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"options": "Tuỳ chọn"
|
||||
},
|
||||
"attribute_detail": {
|
||||
"name": "Tên",
|
||||
"value": "Giá trị",
|
||||
"text": "Văn bản",
|
||||
"number": "Số",
|
||||
"delete": "Xoá"
|
||||
},
|
||||
"rename_note": {
|
||||
"rename_note": "Đổi tên ghi chép"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Thêm nhãn",
|
||||
"label_name_placeholder": "tên nhãn",
|
||||
"help_text_item2": "hoặc thay đổi giá trị của nhãn có sẵn"
|
||||
},
|
||||
"rename_label": {
|
||||
"rename_label": "Đặt lại tên nhãn"
|
||||
},
|
||||
"call_to_action": {
|
||||
"dismiss": "Bỏ qua"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ export default class GlobalMenuWidget extends BasicWidget {
|
||||
}
|
||||
|
||||
async fetchLatestVersion() {
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Notes/releases/latest";
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
|
||||
|
||||
const resp = await fetch(RELEASES_API_URL);
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import utils from "../../services/utils.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import { EventData } from "../../components/app_context.js";
|
||||
import FlexContainer from "./flex_container.js";
|
||||
import options from "../../services/options.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import utils from "../../services/utils.js";
|
||||
|
||||
/**
|
||||
* The root container is the top-most widget/container, from which the entire layout derives.
|
||||
@@ -20,6 +22,7 @@ export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
this.id("root-widget");
|
||||
this.css("height", "100dvh");
|
||||
this.originalViewportHeight = getViewportHeight();
|
||||
|
||||
}
|
||||
|
||||
render(): JQuery<HTMLElement> {
|
||||
@@ -27,15 +30,36 @@ export default class RootContainer extends FlexContainer<BasicWidget> {
|
||||
window.visualViewport?.addEventListener("resize", () => this.#onMobileResize());
|
||||
}
|
||||
|
||||
this.#setMotion(options.is("motionEnabled"));
|
||||
this.#setShadows(options.is("shadowsEnabled"));
|
||||
|
||||
return super.render();
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("motionEnabled")) {
|
||||
this.#setMotion(options.is("motionEnabled"));
|
||||
}
|
||||
|
||||
if (loadResults.isOptionReloaded("shadowsEnabled")) {
|
||||
this.#setShadows(options.is("shadowsEnabled"));
|
||||
}
|
||||
}
|
||||
|
||||
#onMobileResize() {
|
||||
const currentViewportHeight = getViewportHeight();
|
||||
const isKeyboardOpened = (currentViewportHeight < this.originalViewportHeight);
|
||||
this.$widget.toggleClass("virtual-keyboard-opened", isKeyboardOpened);
|
||||
}
|
||||
|
||||
#setMotion(enabled: boolean) {
|
||||
document.body.classList.toggle("motion-disabled", !enabled);
|
||||
jQuery.fx.off = !enabled;
|
||||
}
|
||||
|
||||
#setShadows(enabled: boolean) {
|
||||
document.body.classList.toggle("shadows-disabled", !enabled);
|
||||
}
|
||||
}
|
||||
|
||||
function getViewportHeight() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import FlexContainer from "./flex_container.js";
|
||||
import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import type NoteContext from "../../components/note_context.js";
|
||||
import Component from "../../components/component.js";
|
||||
|
||||
interface NoteContextEvent {
|
||||
noteContext: NoteContext;
|
||||
@@ -152,6 +153,8 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
||||
for (const ntxId of ntxIds) {
|
||||
this.$widget.find(`[data-ntx-id="${ntxId}"]`).remove();
|
||||
|
||||
const widget = this.widgets[ntxId];
|
||||
recursiveCleanup(widget);
|
||||
delete this.widgets[ntxId];
|
||||
}
|
||||
}
|
||||
@@ -237,3 +240,12 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
||||
return Promise.all(promises);
|
||||
}
|
||||
}
|
||||
|
||||
function recursiveCleanup(widget: Component) {
|
||||
for (const child of widget.children) {
|
||||
recursiveCleanup(child);
|
||||
}
|
||||
if ("cleanup" in widget && typeof widget.cleanup === "function") {
|
||||
widget.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ function AddLinkDialogComponent() {
|
||||
}}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("add_link.note")}>
|
||||
<FormGroup label={t("add_link.note")} name="note">
|
||||
<NoteAutocomplete
|
||||
inputRef={autocompleteRef}
|
||||
onChange={setSuggestion}
|
||||
|
||||
@@ -64,7 +64,7 @@ function BranchPrefixDialogComponent() {
|
||||
footer={<Button text={t("branch_prefix.save")} />}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("branch_prefix.prefix")}>
|
||||
<FormGroup label={t("branch_prefix.prefix")} name="prefix">
|
||||
<div class="input-group">
|
||||
<input class="branch-prefix-input form-control" value={prefix} ref={branchInput}
|
||||
onChange={(e) => setPrefix((e.target as HTMLInputElement).value)} />
|
||||
|
||||
@@ -94,7 +94,8 @@ function AvailableActionsList() {
|
||||
<td>{ actionGroup.title }:</td>
|
||||
{actionGroup.actions.map(({ actionName, actionTitle }) =>
|
||||
<Button
|
||||
small text={actionTitle}
|
||||
size="small"
|
||||
text={actionTitle}
|
||||
onClick={() => bulk_action.addAction("_bulkAction", actionName)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
import { CallToAction, dismissCallToAction, getCallToActions } from "./call_to_action_definitions";
|
||||
import { t } from "../../services/i18n";
|
||||
|
||||
function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActions: CallToAction[] }) {
|
||||
if (!activeCallToActions.length) {
|
||||
@@ -25,12 +26,12 @@ function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActi
|
||||
<Modal
|
||||
className="call-to-action"
|
||||
size="md"
|
||||
title="New features"
|
||||
title={activeItem.title}
|
||||
show={shown}
|
||||
onHidden={() => setShown(false)}
|
||||
footerAlignment="between"
|
||||
footer={<>
|
||||
<Button text="Dismiss" onClick={async () => {
|
||||
<Button text={t("call_to_action.dismiss")} onClick={async () => {
|
||||
await dismissCallToAction(activeItem.id);
|
||||
goToNext();
|
||||
}} />
|
||||
@@ -43,7 +44,6 @@ function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActi
|
||||
)}
|
||||
</>}
|
||||
>
|
||||
<h4>{activeItem.title}</h4>
|
||||
<p>{activeItem.message}</p>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ const CALL_TO_ACTIONS: CallToAction[] = [
|
||||
id: "background_effects",
|
||||
title: t("call_to_action.background_effects_title"),
|
||||
message: t("call_to_action.background_effects_message"),
|
||||
enabled: () => utils.isElectron() && window.glob.platform === "win32" && isNextTheme() && !options.is("backgroundEffects"),
|
||||
enabled: () => false,
|
||||
buttons: [
|
||||
{
|
||||
text: t("call_to_action.background_effects_button"),
|
||||
|
||||
@@ -69,15 +69,15 @@ function CloneToDialogComponent() {
|
||||
>
|
||||
<h5>{t("clone_to.notes_to_clone")}</h5>
|
||||
<NoteList style={{ maxHeight: "200px", overflow: "auto" }} noteIds={clonedNoteIds} />
|
||||
<FormGroup label={t("clone_to.target_parent_note")}>
|
||||
<FormGroup name="target-parent-note" label={t("clone_to.target_parent_note")}>
|
||||
<NoteAutocomplete
|
||||
placeholder={t("clone_to.search_for_note_by_its_name")}
|
||||
onChange={setSuggestion}
|
||||
inputRef={autoCompleteRef}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label={t("clone_to.prefix_optional")} title={t("clone_to.cloned_note_prefix_title")}>
|
||||
<FormTextBox name="clone-prefix" onChange={setPrefix} />
|
||||
<FormGroup name="clone-prefix" label={t("clone_to.prefix_optional")} title={t("clone_to.cloned_note_prefix_title")}>
|
||||
<FormTextBox onChange={setPrefix} />
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import tree from "../../services/tree";
|
||||
import Button from "../react/Button";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import FormFileUpload from "../react/FormFileUpload";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import FormGroup, { FormMultiGroup } from "../react/FormGroup";
|
||||
import Modal from "../react/Modal";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||
@@ -55,11 +55,11 @@ function ImportDialogComponent() {
|
||||
footer={<Button text={t("import.import")} primary disabled={!files} />}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("import.chooseImportFile")} description={<>{t("import.importDescription")} <strong>{ noteTitle }</strong></>}>
|
||||
<FormGroup name="files" label={t("import.chooseImportFile")} description={<>{t("import.importDescription")} <strong>{ noteTitle }</strong></>}>
|
||||
<FormFileUpload multiple onChange={setFiles} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label={t("import.options")}>
|
||||
<FormMultiGroup label={t("import.options")}>
|
||||
<FormCheckbox
|
||||
name="safe-import" hint={t("import.safeImportTooltip")} label={t("import.safeImport")}
|
||||
currentValue={safeImport} onChange={setSafeImport}
|
||||
@@ -84,7 +84,7 @@ function ImportDialogComponent() {
|
||||
name="replace-underscores-with-spaces" label={t("import.replaceUnderscoresWithSpaces")}
|
||||
currentValue={replaceUnderscoresWithSpaces} onChange={setReplaceUnderscoresWithSpaces}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormMultiGroup>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ function IncludeNoteDialogComponent() {
|
||||
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("include_note.label_note")}>
|
||||
<FormGroup name="note" label={t("include_note.label_note")}>
|
||||
<NoteAutocomplete
|
||||
placeholder={t("include_note.placeholder_search")}
|
||||
onChange={setSuggestion}
|
||||
@@ -55,8 +55,9 @@ function IncludeNoteDialogComponent() {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label={t("include_note.box_size_prompt")}>
|
||||
<FormRadioGroup name="include-note-box-size"
|
||||
<FormGroup name="include-note-box-size" label={t("include_note.box_size_prompt")}>
|
||||
<FormRadioGroup
|
||||
name="include-note-box-size"
|
||||
currentValue={boxSize} onChange={setBoxSize}
|
||||
values={[
|
||||
{ label: t("include_note.box_size_small"), value: "small" },
|
||||
|
||||
@@ -57,7 +57,7 @@ function MoveToDialogComponent() {
|
||||
<h5>{t("move_to.notes_to_move")}</h5>
|
||||
<NoteList branchIds={movedBranchIds} />
|
||||
|
||||
<FormGroup label={t("move_to.target_parent_note")}>
|
||||
<FormGroup name="parent-note" label={t("move_to.target_parent_note")}>
|
||||
<NoteAutocomplete
|
||||
onChange={setSuggestion}
|
||||
inputRef={autoCompleteRef}
|
||||
|
||||
@@ -83,7 +83,7 @@ function NoteTypeChooserDialogComponent() {
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
<FormGroup label={t("note_type_chooser.change_path_prompt")}>
|
||||
<FormGroup name="parent-note" label={t("note_type_chooser.change_path_prompt")}>
|
||||
<NoteAutocomplete
|
||||
onChange={setParentNote}
|
||||
placeholder={t("note_type_chooser.search_placeholder")}
|
||||
@@ -95,7 +95,7 @@ function NoteTypeChooserDialogComponent() {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label={t("note_type_chooser.modal_body")}>
|
||||
<FormGroup name="note-type" label={t("note_type_chooser.modal_body")}>
|
||||
<FormList onSelect={onNoteTypeSelected}>
|
||||
{noteTypes.map((_item) => {
|
||||
if (_item.title === "----") {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface PromptDialogOptions {
|
||||
defaultValue?: string;
|
||||
shown?: PromptShownDialogCallback;
|
||||
callback?: (value: string | null) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function PromptDialogComponent() {
|
||||
@@ -32,24 +33,26 @@ function PromptDialogComponent() {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const labelRef = useRef<HTMLLabelElement>(null);
|
||||
const answerRef = useRef<HTMLInputElement>(null);
|
||||
const [ opts, setOpts ] = useState<PromptDialogOptions>();
|
||||
const opts = useRef<PromptDialogOptions>();
|
||||
const [ value, setValue ] = useState("");
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const submitValue = useRef<string>(null);
|
||||
|
||||
useTriliumEvent("showPromptDialog", (opts) => {
|
||||
setOpts(opts);
|
||||
useTriliumEvent("showPromptDialog", (newOpts) => {
|
||||
opts.current = newOpts;
|
||||
setValue(newOpts.defaultValue ?? "");
|
||||
setShown(true);
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="prompt-dialog"
|
||||
title={opts?.title ?? t("prompt.title")}
|
||||
title={opts.current?.title ?? t("prompt.title")}
|
||||
size="lg"
|
||||
zIndex={2000}
|
||||
modalRef={modalRef} formRef={formRef}
|
||||
onShown={() => {
|
||||
opts?.shown?.({
|
||||
opts.current?.shown?.({
|
||||
$dialog: refToJQuerySelector(modalRef),
|
||||
$question: refToJQuerySelector(labelRef),
|
||||
$answer: refToJQuerySelector(answerRef),
|
||||
@@ -58,24 +61,25 @@ function PromptDialogComponent() {
|
||||
answerRef.current?.focus();
|
||||
}}
|
||||
onSubmit={() => {
|
||||
const modal = BootstrapModal.getOrCreateInstance(modalRef.current!);
|
||||
modal.hide();
|
||||
|
||||
opts?.callback?.(value);
|
||||
submitValue.current = value;
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => {
|
||||
opts?.callback?.(null);
|
||||
setShown(false);
|
||||
opts.current?.callback?.(submitValue.current);
|
||||
submitValue.current = null;
|
||||
opts.current = undefined;
|
||||
}}
|
||||
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" primary />}
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
<FormGroup label={opts?.message} labelRef={labelRef}>
|
||||
<FormGroup name="prompt-dialog-answer" label={opts.current?.message} labelRef={labelRef}>
|
||||
<FormTextBox
|
||||
name="prompt-dialog-answer"
|
||||
inputRef={answerRef}
|
||||
currentValue={value} onChange={setValue} />
|
||||
currentValue={value} onChange={setValue}
|
||||
readOnly={opts.current?.readOnly}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -57,7 +57,8 @@ function RecentChangesDialogComponent() {
|
||||
header={
|
||||
<Button
|
||||
text={t("recent_changes.erase_notes_button")}
|
||||
small style={{ padding: "0 10px" }}
|
||||
size="small"
|
||||
style={{ padding: "0 10px" }}
|
||||
onClick={() => {
|
||||
server.post("notes/erase-deleted-notes-now").then(() => {
|
||||
setNeedsRefresh(true);
|
||||
|
||||
@@ -55,7 +55,7 @@ function RevisionsDialogComponent() {
|
||||
helpPageId="vZWERwf8U3nx"
|
||||
bodyStyle={{ display: "flex", height: "80vh" }}
|
||||
header={
|
||||
(!!revisions?.length && <Button text={t("revisions.delete_all_revisions")} small style={{ padding: "0 10px" }}
|
||||
(!!revisions?.length && <Button text={t("revisions.delete_all_revisions")} size="small" style={{ padding: "0 10px" }}
|
||||
onClick={async () => {
|
||||
const text = t("revisions.confirm_delete_all");
|
||||
|
||||
|
||||
@@ -83,11 +83,8 @@ function SortChildNotesDialogComponent() {
|
||||
label={t("sort_child_notes.sort_with_respect_to_different_character_sorting")}
|
||||
currentValue={sortNatural} onChange={setSortNatural}
|
||||
/>
|
||||
<FormGroup className="form-check" label={t("sort_child_notes.natural_sort_language")} description={t("sort_child_notes.the_language_code_for_natural_sort")}>
|
||||
<FormTextBox
|
||||
name="sort-locale"
|
||||
currentValue={sortLocale} onChange={setSortLocale}
|
||||
/>
|
||||
<FormGroup name="sort-locale" className="form-check" label={t("sort_child_notes.natural_sort_language")} description={t("sort_child_notes.the_language_code_for_natural_sort")}>
|
||||
<FormTextBox currentValue={sortLocale} onChange={setSortLocale} />
|
||||
</FormGroup>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -51,13 +51,12 @@ function UploadAttachmentsDialogComponent() {
|
||||
onHidden={() => setShown(false)}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup label={t("upload_attachments.choose_files")} description={description}>
|
||||
<FormGroup name="files" label={t("upload_attachments.choose_files")} description={description}>
|
||||
<FormFileUpload onChange={setFiles} multiple />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label={t("upload_attachments.options")}>
|
||||
<FormGroup name="shrink-images" label={t("upload_attachments.options")}>
|
||||
<FormCheckbox
|
||||
name="shrink-images"
|
||||
hint={t("upload_attachments.tooltip")} label={t("upload_attachments.shrink_images")}
|
||||
currentValue={shrinkImages} onChange={setShrinkImages}
|
||||
/>
|
||||
|
||||
@@ -93,6 +93,8 @@ interface QuickSearchResponse {
|
||||
highlightedNotePathTitle: string;
|
||||
contentSnippet?: string;
|
||||
highlightedContentSnippet?: string;
|
||||
attributeSnippet?: string;
|
||||
highlightedAttributeSnippet?: string;
|
||||
icon: string;
|
||||
}>;
|
||||
error: string;
|
||||
@@ -241,7 +243,12 @@ export default class QuickSearchWidget extends BasicWidget {
|
||||
<span style="flex: 1;" class="search-result-title">${result.highlightedNotePathTitle}</span>
|
||||
</div>`;
|
||||
|
||||
// Add content snippet below the title if available
|
||||
// Add attribute snippet (tags/attributes) below the title if available
|
||||
if (result.highlightedAttributeSnippet) {
|
||||
itemHtml += `<div style="font-size: 0.75em; color: var(--muted-text-color); opacity: 0.5; margin-left: 20px; margin-top: 2px; line-height: 1.2;" class="search-result-attributes">${result.highlightedAttributeSnippet}</div>`;
|
||||
}
|
||||
|
||||
// Add content snippet below the attributes if available
|
||||
if (result.highlightedContentSnippet) {
|
||||
itemHtml += `<div style="font-size: 0.85em; color: var(--main-text-color); opacity: 0.7; margin-left: 20px; margin-top: 4px; line-height: 1.3;" class="search-result-content">${result.highlightedContentSnippet}</div>`;
|
||||
}
|
||||
|
||||
14
apps/client/src/widgets/react/Admonition.tsx
Normal file
14
apps/client/src/widgets/react/Admonition.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
|
||||
interface AdmonitionProps {
|
||||
type: "warning" | "note" | "caution";
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
export default function Admonition({ type, children }: AdmonitionProps) {
|
||||
return (
|
||||
<div className={`admonition ${type}`} role="alert">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
|
||||
interface AlertProps {
|
||||
type: "info" | "danger";
|
||||
type: "info" | "danger" | "warning";
|
||||
title?: string;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRef, useMemo } from "preact/hooks";
|
||||
import { memo } from "preact/compat";
|
||||
|
||||
interface ButtonProps {
|
||||
name?: string;
|
||||
/** Reference to the button element. Mostly useful for requesting focus. */
|
||||
buttonRef?: RefObject<HTMLButtonElement>;
|
||||
text: string;
|
||||
@@ -14,11 +15,11 @@ interface ButtonProps {
|
||||
onClick?: () => void;
|
||||
primary?: boolean;
|
||||
disabled?: boolean;
|
||||
small?: boolean;
|
||||
size?: "normal" | "small" | "micro";
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, small, style }: ButtonProps) => {
|
||||
const Button = memo(({ name, buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, size, style }: ButtonProps) => {
|
||||
// Memoize classes array to prevent recreation
|
||||
const classes = useMemo(() => {
|
||||
const classList: string[] = ["btn"];
|
||||
@@ -30,11 +31,13 @@ const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboard
|
||||
if (className) {
|
||||
classList.push(className);
|
||||
}
|
||||
if (small) {
|
||||
if (size === "small") {
|
||||
classList.push("btn-sm");
|
||||
} else if (size === "micro") {
|
||||
classList.push("btn-micro");
|
||||
}
|
||||
return classList.join(" ");
|
||||
}, [primary, className, small]);
|
||||
}, [primary, className, size]);
|
||||
|
||||
const buttonRef = _buttonRef ?? useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -52,6 +55,7 @@ const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboard
|
||||
|
||||
return (
|
||||
<button
|
||||
name={name}
|
||||
className={classes}
|
||||
type={onClick ? "button" : "submit"}
|
||||
onClick={onClick}
|
||||
|
||||
17
apps/client/src/widgets/react/Column.tsx
Normal file
17
apps/client/src/widgets/react/Column.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { CSSProperties } from "preact/compat";
|
||||
|
||||
interface ColumnProps {
|
||||
md?: number;
|
||||
children: ComponentChildren;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function Column({ md, children, className, style }: ColumnProps) {
|
||||
return (
|
||||
<div className={`col-md-${md ?? 6} ${className ?? ""}`} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import { Tooltip } from "bootstrap";
|
||||
import { useEffect, useRef, useMemo, useCallback } from "preact/hooks";
|
||||
import { escapeQuotes } from "../../services/utils";
|
||||
import { ComponentChildren } from "preact";
|
||||
import { memo } from "preact/compat";
|
||||
import { CSSProperties, memo } from "preact/compat";
|
||||
import { useUniqueName } from "./hooks";
|
||||
|
||||
interface FormCheckboxProps {
|
||||
name: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
label: string | ComponentChildren;
|
||||
/**
|
||||
* If set, the checkbox label will be underlined and dotted, indicating a hint. When hovered, it will show the hint text.
|
||||
@@ -14,9 +16,11 @@ interface FormCheckboxProps {
|
||||
currentValue: boolean;
|
||||
disabled?: boolean;
|
||||
onChange(newValue: boolean): void;
|
||||
containerStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint }: FormCheckboxProps) => {
|
||||
const FormCheckbox = memo(({ name, id: _id, disabled, label, currentValue, onChange, hint, containerStyle }: FormCheckboxProps) => {
|
||||
const id = _id ?? useUniqueName(name);
|
||||
const labelRef = useRef<HTMLLabelElement>(null);
|
||||
|
||||
// Fix: Move useEffect outside conditional
|
||||
@@ -46,7 +50,7 @@ const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint
|
||||
const titleText = useMemo(() => hint ? escapeQuotes(hint) : undefined, [hint]);
|
||||
|
||||
return (
|
||||
<div className="form-checkbox">
|
||||
<div className="form-checkbox" style={containerStyle}>
|
||||
<label
|
||||
className="form-check-label tn-checkbox"
|
||||
style={labelStyle}
|
||||
@@ -54,9 +58,10 @@ const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint
|
||||
ref={labelRef}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
name={name}
|
||||
name={id}
|
||||
checked={currentValue || false}
|
||||
value="1"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
import { ComponentChildren, RefObject } from "preact";
|
||||
import { cloneElement, ComponentChildren, RefObject, VNode } from "preact";
|
||||
import { CSSProperties } from "preact/compat";
|
||||
import { useUniqueName } from "./hooks";
|
||||
|
||||
interface FormGroupProps {
|
||||
name: string;
|
||||
labelRef?: RefObject<HTMLLabelElement>;
|
||||
label?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children: ComponentChildren;
|
||||
children: VNode<any>;
|
||||
description?: string | ComponentChildren;
|
||||
disabled?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function FormGroup({ label, title, className, children, description, labelRef }: FormGroupProps) {
|
||||
export default function FormGroup({ name, label, title, className, children, description, labelRef, disabled, style }: FormGroupProps) {
|
||||
const id = useUniqueName(name);
|
||||
const childWithId = cloneElement(children, { id });
|
||||
|
||||
return (
|
||||
<div className={`form-group ${className}`} title={title}
|
||||
style={{ "margin-bottom": "15px" }}>
|
||||
<label style={{ width: "100%" }} ref={labelRef}>
|
||||
{label && <div style={{ "margin-bottom": "10px" }}>{label}</div> }
|
||||
{children}
|
||||
</label>
|
||||
<div className={`form-group ${className} ${disabled ? "disabled" : ""}`} title={title} style={style}>
|
||||
{ label &&
|
||||
<label style={{ width: "100%" }} ref={labelRef} htmlFor={id}>{label}</label>}
|
||||
|
||||
{childWithId}
|
||||
|
||||
{description && <small className="form-text">{description}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link FormGroup} but allows more than one child. Due to this behaviour, there is no automatic ID assignment.
|
||||
*/
|
||||
export function FormMultiGroup({ label, children }: { label: string, children: ComponentChildren }) {
|
||||
return (
|
||||
<div className={`form-group`}>
|
||||
{label && <label>{label}</label>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,56 @@
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { useUniqueName } from "./hooks";
|
||||
|
||||
interface FormRadioProps {
|
||||
name: string;
|
||||
currentValue?: string;
|
||||
values: {
|
||||
value: string;
|
||||
label: string;
|
||||
label: string | ComponentChildren;
|
||||
inlineDescription?: string | ComponentChildren;
|
||||
}[];
|
||||
onChange(newValue: string): void;
|
||||
}
|
||||
|
||||
export default function FormRadioGroup({ name, values, currentValue, onChange }: FormRadioProps) {
|
||||
export default function FormRadioGroup({ values, ...restProps }: FormRadioProps) {
|
||||
return (
|
||||
<>
|
||||
{(values || []).map(({ value, label }) => (
|
||||
<div className="form-check">
|
||||
<label className="form-check-label tn-radio">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name={name}
|
||||
value={value}
|
||||
checked={value === currentValue}
|
||||
onChange={e => onChange((e.target as HTMLInputElement).value)} />
|
||||
{label}
|
||||
</label>
|
||||
<div role="group">
|
||||
{(values || []).map(({ value, label, inlineDescription }) => (
|
||||
<div className="form-checkbox">
|
||||
<FormRadio
|
||||
value={value}
|
||||
label={label} inlineDescription={inlineDescription}
|
||||
labelClassName="form-check-label"
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormInlineRadioGroup({ values, ...restProps }: FormRadioProps) {
|
||||
return (
|
||||
<div role="group">
|
||||
{values.map(({ value, label }) => (<FormRadio value={value} label={label} {...restProps} />))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormRadio({ name, value, label, currentValue, onChange, labelClassName, inlineDescription }: Omit<FormRadioProps, "values"> & { value: string, label: ComponentChildren, inlineDescription?: ComponentChildren, labelClassName?: string }) {
|
||||
return (
|
||||
<label className={`tn-radio ${labelClassName ?? ""}`}>
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name={useUniqueName(name)}
|
||||
value={value}
|
||||
checked={value === currentValue}
|
||||
onChange={e => onChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{inlineDescription ?
|
||||
<><strong>{label}</strong> - {inlineDescription}</>
|
||||
: label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
79
apps/client/src/widgets/react/FormSelect.tsx
Normal file
79
apps/client/src/widgets/react/FormSelect.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { CSSProperties } from "preact/compat";
|
||||
|
||||
type OnChangeListener = (newValue: string) => void;
|
||||
|
||||
export interface FormSelectGroup<T> {
|
||||
title: string;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
interface ValueConfig<T, Q> {
|
||||
values: Q[];
|
||||
/** The property of an item of {@link values} to be used as the key, uniquely identifying it. The key will be passed to the change listener. */
|
||||
keyProperty: keyof T;
|
||||
/** The property of an item of {@link values} to be used as the label, representing a human-readable version of the key. If missing, {@link keyProperty} will be used instead. */
|
||||
titleProperty?: keyof T;
|
||||
/** The current value of the combobox. The value will be looked up by going through {@link values} and looking an item whose {@link #keyProperty} value matches this one */
|
||||
currentValue?: string;
|
||||
}
|
||||
|
||||
interface FormSelectProps<T, Q> extends ValueConfig<T, Q> {
|
||||
id?: string;
|
||||
onChange: OnChangeListener;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combobox component that takes in any object array as data. Each item of the array is rendered as an item, and the key and values are obtained by looking into the object by a specified key.
|
||||
*/
|
||||
export default function FormSelect<T>({ id, onChange, style, ...restProps }: FormSelectProps<T, T>) {
|
||||
return (
|
||||
<FormSelectBody id={id} onChange={onChange} style={style}>
|
||||
<FormSelectGroup {...restProps} />
|
||||
</FormSelectBody>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link FormSelect}, but the top-level elements are actually groups.
|
||||
*/
|
||||
export function FormSelectWithGroups<T>({ id, values, keyProperty, titleProperty, currentValue, onChange }: FormSelectProps<T, FormSelectGroup<T>>) {
|
||||
return (
|
||||
<FormSelectBody id={id} onChange={onChange}>
|
||||
{values.map(({ title, items }) => {
|
||||
return (
|
||||
<optgroup label={title}>
|
||||
<FormSelectGroup values={items} keyProperty={keyProperty} titleProperty={titleProperty} currentValue={currentValue} />
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</FormSelectBody>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSelectBody({ id, children, onChange, style }: { id?: string, children: ComponentChildren, onChange: OnChangeListener, style?: CSSProperties }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
class="form-select"
|
||||
onChange={e => onChange((e.target as HTMLInputElement).value)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSelectGroup<T>({ values, keyProperty, titleProperty, currentValue }: ValueConfig<T, T>) {
|
||||
return values.map(item => {
|
||||
return (
|
||||
<option
|
||||
value={item[keyProperty] as any}
|
||||
selected={item[keyProperty] === currentValue}
|
||||
>
|
||||
{item[titleProperty ?? keyProperty] ?? item[keyProperty] as any}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
}
|
||||
5
apps/client/src/widgets/react/FormText.tsx
Normal file
5
apps/client/src/widgets/react/FormText.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
|
||||
export default function FormText({ children }: { children: ComponentChildren }) {
|
||||
return <p className="form-text use-tn-links">{children}</p>
|
||||
}
|
||||
18
apps/client/src/widgets/react/FormTextArea.tsx
Normal file
18
apps/client/src/widgets/react/FormTextArea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
interface FormTextAreaProps {
|
||||
id?: string;
|
||||
currentValue: string;
|
||||
onBlur?(newValue: string): void;
|
||||
rows: number;
|
||||
}
|
||||
export default function FormTextArea({ id, onBlur, rows, currentValue }: FormTextAreaProps) {
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
rows={rows}
|
||||
onBlur={(e) => {
|
||||
onBlur?.(e.currentTarget.value);
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
>{currentValue}</textarea>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,48 @@
|
||||
import type { InputHTMLAttributes, RefObject } from "preact/compat";
|
||||
|
||||
interface FormTextBoxProps extends Pick<InputHTMLAttributes<HTMLInputElement>, "placeholder" | "autoComplete" | "className" | "type" | "name" | "pattern" | "title" | "style"> {
|
||||
interface FormTextBoxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "onBlur" | "value"> {
|
||||
id?: string;
|
||||
currentValue?: string;
|
||||
onChange?(newValue: string): void;
|
||||
onChange?(newValue: string, validity: ValidityState): void;
|
||||
onBlur?(newValue: string): void;
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export default function FormTextBox({ id, type, name, className, currentValue, onChange, autoComplete, inputRef, placeholder, title, pattern, style }: FormTextBoxProps) {
|
||||
export default function FormTextBox({ inputRef, className, type, currentValue, onChange, onBlur,...rest}: FormTextBoxProps) {
|
||||
if (type === "number" && currentValue) {
|
||||
const { min, max } = rest;
|
||||
const currentValueNum = parseInt(currentValue, 10);
|
||||
if (min && currentValueNum < parseInt(String(min), 10)) {
|
||||
currentValue = String(min);
|
||||
} else if (max && currentValueNum > parseInt(String(max), 10)) {
|
||||
currentValue = String(max);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={type ?? "text"}
|
||||
className={`form-control ${className ?? ""}`}
|
||||
id={id}
|
||||
name={name}
|
||||
type={type ?? "text"}
|
||||
value={currentValue}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder}
|
||||
title={title}
|
||||
pattern={pattern}
|
||||
onInput={e => onChange?.(e.currentTarget.value)}
|
||||
style={style}
|
||||
onInput={onChange && (e => {
|
||||
const target = e.currentTarget;
|
||||
onChange?.(target.value, target.validity);
|
||||
})}
|
||||
onBlur={onBlur && (e => {
|
||||
const target = e.currentTarget;
|
||||
onBlur(target.value);
|
||||
})}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormTextBoxWithUnit(props: FormTextBoxProps & { unit: string }) {
|
||||
return (
|
||||
<label class="input-group tn-number-unit-pair">
|
||||
<FormTextBox {...props} />
|
||||
<span class="input-group-text">{props.unit}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
33
apps/client/src/widgets/react/KeyboardShortcut.tsx
Normal file
33
apps/client/src/widgets/react/KeyboardShortcut.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ActionKeyboardShortcut, KeyboardActionNames } from "@triliumnext/commons";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import keyboard_actions from "../../services/keyboard_actions";
|
||||
|
||||
interface KeyboardShortcutProps {
|
||||
actionName: KeyboardActionNames;
|
||||
}
|
||||
|
||||
export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps) {
|
||||
|
||||
const [ action, setAction ] = useState<ActionKeyboardShortcut>();
|
||||
useEffect(() => {
|
||||
keyboard_actions.getAction(actionName).then(setAction);
|
||||
}, []);
|
||||
|
||||
if (!action) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{action.effectiveShortcuts?.map((shortcut, i) => {
|
||||
const keys = shortcut.split("+");
|
||||
return keys
|
||||
.map((key, i) => (
|
||||
<>
|
||||
<kbd>{key}</kbd> {i + 1 < keys.length && "+ "}
|
||||
</>
|
||||
))
|
||||
}).reduce<any>((acc, item) => (acc.length ? [...acc, ", ", item] : [item]), [])}
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
apps/client/src/widgets/react/LinkButton.tsx
Normal file
17
apps/client/src/widgets/react/LinkButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ComponentChild } from "preact";
|
||||
|
||||
interface LinkButtonProps {
|
||||
onClick: () => void;
|
||||
text: ComponentChild;
|
||||
}
|
||||
|
||||
export default function LinkButton({ onClick, text }: LinkButtonProps) {
|
||||
return (
|
||||
<a class="tn-link" href="javascript:" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}>
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
|
||||
interface NoteAutocompleteProps {
|
||||
id?: string;
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
text?: string;
|
||||
placeholder?: string;
|
||||
@@ -18,7 +19,7 @@ interface NoteAutocompleteProps {
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
export default function NoteAutocomplete({ inputRef: _ref, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged }: NoteAutocompleteProps) {
|
||||
export default function NoteAutocomplete({ id, inputRef: _ref, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged }: NoteAutocompleteProps) {
|
||||
const ref = _ref ?? useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,6 +75,7 @@ export default function NoteAutocomplete({ inputRef: _ref, text, placeholder, on
|
||||
return (
|
||||
<div className="input-group" style={containerStyle}>
|
||||
<input
|
||||
id={id}
|
||||
ref={ref}
|
||||
className="note-autocomplete form-control"
|
||||
placeholder={placeholder ?? t("add_link.search_note")} />
|
||||
|
||||
@@ -24,7 +24,7 @@ function getProps({ className, html, style }: RawHtmlProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||
export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||
if (typeof html === "object" && "length" in html) {
|
||||
html = html[0];
|
||||
}
|
||||
|
||||
@@ -22,11 +22,18 @@ export default abstract class ReactBasicWidget extends BasicWidget {
|
||||
* @returns the rendered wrapped DOM element.
|
||||
*/
|
||||
export function renderReactWidget(parentComponent: Component, el: JSX.Element) {
|
||||
const renderContainer = new DocumentFragment();
|
||||
return renderReactWidgetAtElement(parentComponent, el, new DocumentFragment()).children();
|
||||
}
|
||||
|
||||
export function renderReactWidgetAtElement(parentComponent: Component, el: JSX.Element, container: Element | DocumentFragment) {
|
||||
render((
|
||||
<ParentComponent.Provider value={parentComponent}>
|
||||
{el}
|
||||
</ParentComponent.Provider>
|
||||
), renderContainer);
|
||||
return $(renderContainer.firstChild as HTMLElement);
|
||||
), container);
|
||||
return $(container) as JQuery<HTMLElement>;
|
||||
}
|
||||
|
||||
export function disposeReactWidget(container: Element) {
|
||||
render(null, container);
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { EventData, EventNames } from "../../components/app_context";
|
||||
import { ParentComponent } from "./ReactBasicWidget";
|
||||
import SpacedUpdate from "../../services/spaced_update";
|
||||
import { OptionNames } from "@triliumnext/commons";
|
||||
import options, { type OptionValue } from "../../services/options";
|
||||
import utils, { reloadFrontendApp } from "../../services/utils";
|
||||
import Component from "../../components/component";
|
||||
import server from "../../services/server";
|
||||
|
||||
type TriliumEventHandler<T extends EventNames> = (data: EventData<T>) => void;
|
||||
const registeredHandlers: Map<Component, Map<EventNames, TriliumEventHandler<any>[]>> = new Map();
|
||||
|
||||
/**
|
||||
* Allows a React component to react to Trilium events (e.g. `entitiesReloaded`). When the desired event is triggered, the handler is invoked with the event parameters.
|
||||
@@ -12,32 +20,67 @@ import SpacedUpdate from "../../services/spaced_update";
|
||||
* @param handler the handler to be invoked when the event is triggered.
|
||||
* @param enabled determines whether the event should be listened to or not. Useful to conditionally limit the listener based on a state (e.g. a modal being displayed).
|
||||
*/
|
||||
export default function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void, enabled = true) {
|
||||
export default function useTriliumEvent<T extends EventNames>(eventName: T, handler: TriliumEventHandler<T>, enabled = true) {
|
||||
const parentWidget = useContext(ParentComponent);
|
||||
if (!parentWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlerName = `${eventName}Event`;
|
||||
const customHandler = useMemo(() => {
|
||||
return async (data: EventData<T>) => {
|
||||
// Inform the attached event listeners.
|
||||
const eventHandlers = registeredHandlers.get(parentWidget)?.get(eventName) ?? [];
|
||||
for (const eventHandler of eventHandlers) {
|
||||
eventHandler(data);
|
||||
}
|
||||
}
|
||||
}, [ eventName, parentWidget ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parentWidget || !enabled) {
|
||||
return;
|
||||
// Attach to the list of handlers.
|
||||
let handlersByWidget = registeredHandlers.get(parentWidget);
|
||||
if (!handlersByWidget) {
|
||||
handlersByWidget = new Map();
|
||||
registeredHandlers.set(parentWidget, handlersByWidget);
|
||||
}
|
||||
|
||||
// Create a unique handler name for this specific event listener
|
||||
const handlerName = `${eventName}Event`;
|
||||
const originalHandler = parentWidget[handlerName];
|
||||
let handlersByWidgetAndEventName = handlersByWidget.get(eventName);
|
||||
if (!handlersByWidgetAndEventName) {
|
||||
handlersByWidgetAndEventName = [];
|
||||
handlersByWidget.set(eventName, handlersByWidgetAndEventName);
|
||||
}
|
||||
|
||||
// Override the event handler to call our handler
|
||||
parentWidget[handlerName] = async function(data: EventData<T>) {
|
||||
// Call original handler if it exists
|
||||
if (originalHandler) {
|
||||
await originalHandler.call(parentWidget, data);
|
||||
}
|
||||
// Call our React component's handler
|
||||
handler(data);
|
||||
};
|
||||
if (!handlersByWidgetAndEventName.includes(handler)) {
|
||||
handlersByWidgetAndEventName.push(handler);
|
||||
}
|
||||
|
||||
// Apply the custom event handler.
|
||||
if (parentWidget[handlerName] && parentWidget[handlerName] !== customHandler) {
|
||||
console.warn(`Widget ${parentWidget.componentId} already had an event listener and it was replaced by the React one.`);
|
||||
}
|
||||
|
||||
parentWidget[handlerName] = customHandler;
|
||||
|
||||
// Cleanup: restore original handler on unmount or when disabled
|
||||
return () => {
|
||||
parentWidget[handlerName] = originalHandler;
|
||||
const eventHandlers = registeredHandlers.get(parentWidget)?.get(eventName);
|
||||
if (!eventHandlers || !eventHandlers.includes(handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the event handler from the array.
|
||||
const newEventHandlers = eventHandlers.filter(e => e !== handler);
|
||||
if (newEventHandlers.length) {
|
||||
registeredHandlers.get(parentWidget)?.set(eventName, newEventHandlers);
|
||||
} else {
|
||||
registeredHandlers.get(parentWidget)?.delete(eventName);
|
||||
}
|
||||
|
||||
if (!registeredHandlers.get(parentWidget)?.size) {
|
||||
registeredHandlers.delete(parentWidget);
|
||||
}
|
||||
};
|
||||
}, [parentWidget, enabled, eventName, handler]);
|
||||
}, [ eventName, parentWidget, handler ]);
|
||||
}
|
||||
|
||||
export function useSpacedUpdate(callback: () => Promise<void>, interval = 1000) {
|
||||
@@ -64,3 +107,115 @@ export function useSpacedUpdate(callback: () => Promise<void>, interval = 1000)
|
||||
|
||||
return spacedUpdateRef.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows a React component to read and write a Trilium option, while also watching for external changes.
|
||||
*
|
||||
* Conceptually, `useTriliumOption` works just like `useState`, but the value is also automatically updated if
|
||||
* the option is changed somewhere else in the client.
|
||||
*
|
||||
* @param name the name of the option to listen for.
|
||||
* @param needsRefresh whether to reload the frontend whenever the value is changed.
|
||||
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||
*/
|
||||
export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [string, (newValue: OptionValue) => Promise<void>] {
|
||||
const initialValue = options.get(name);
|
||||
const [ value, setValue ] = useState(initialValue);
|
||||
|
||||
const wrappedSetValue = useMemo(() => {
|
||||
return async (newValue: OptionValue) => {
|
||||
await options.save(name, newValue);
|
||||
|
||||
if (needsRefresh) {
|
||||
reloadFrontendApp(`option change: ${name}`);
|
||||
}
|
||||
}
|
||||
}, [ name, needsRefresh ]);
|
||||
|
||||
useTriliumEvent("entitiesReloaded", useCallback(({ loadResults }) => {
|
||||
if (loadResults.getOptionNames().includes(name)) {
|
||||
const newValue = options.get(name);
|
||||
setValue(newValue);
|
||||
}
|
||||
}, [ name ]));
|
||||
|
||||
return [
|
||||
value,
|
||||
wrappedSetValue
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link useTriliumOption}, but the value is converted to and from a boolean instead of a string.
|
||||
*
|
||||
* @param name the name of the option to listen for.
|
||||
* @param needsRefresh whether to reload the frontend whenever the value is changed.
|
||||
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||
*/
|
||||
export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean): [boolean, (newValue: boolean) => Promise<void>] {
|
||||
const [ value, setValue ] = useTriliumOption(name, needsRefresh);
|
||||
return [
|
||||
(value === "true"),
|
||||
(newValue) => setValue(newValue ? "true" : "false")
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link useTriliumOption}, but the value is converted to and from a int instead of a string.
|
||||
*
|
||||
* @param name the name of the option to listen for.
|
||||
* @param needsRefresh whether to reload the frontend whenever the value is changed.
|
||||
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||
*/
|
||||
export function useTriliumOptionInt(name: OptionNames): [number, (newValue: number) => Promise<void>] {
|
||||
const [ value, setValue ] = useTriliumOption(name);
|
||||
return [
|
||||
(parseInt(value, 10)),
|
||||
(newValue) => setValue(newValue)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link useTriliumOption}, but the object value is parsed to and from a JSON instead of a string.
|
||||
*
|
||||
* @param name the name of the option to listen for.
|
||||
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||
*/
|
||||
export function useTriliumOptionJson<T>(name: OptionNames): [ T, (newValue: T) => Promise<void> ] {
|
||||
const [ value, setValue ] = useTriliumOption(name);
|
||||
return [
|
||||
(JSON.parse(value) as T),
|
||||
(newValue => setValue(JSON.stringify(newValue)))
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to {@link useTriliumOption}, but operates with multiple options at once.
|
||||
*
|
||||
* @param names the name of the option to listen for.
|
||||
* @returns an array where the first value is a map where the keys are the option names and the values, and the second value is the setter which takes in the same type of map and saves them all at once.
|
||||
*/
|
||||
export function useTriliumOptions<T extends OptionNames>(...names: T[]) {
|
||||
const values: Record<string, string> = {};
|
||||
for (const name of names) {
|
||||
values[name] = options.get(name);
|
||||
}
|
||||
|
||||
return [
|
||||
values as Record<T, string>,
|
||||
options.saveMany
|
||||
] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique name via a random alphanumeric string of a fixed length.
|
||||
*
|
||||
* <p>
|
||||
* Generally used to assign names to inputs that are unique, especially useful for widgets inside tabs.
|
||||
*
|
||||
* @param prefix a prefix to add to the unique name.
|
||||
* @returns a name with the given prefix and a random alpanumeric string appended to it.
|
||||
*/
|
||||
export function useUniqueName(prefix?: string) {
|
||||
return useMemo(() => (prefix ? prefix + "-" : "") + utils.randomString(10), [ prefix ]);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import ElectronIntegrationOptions from "./options/appearance/electron_integration.js";
|
||||
import ThemeOptions from "./options/appearance/theme.js";
|
||||
import FontsOptions from "./options/appearance/fonts.js";
|
||||
import MaxContentWidthOptions from "./options/appearance/max_content_width.js";
|
||||
import KeyboardShortcutsOptions from "./options/shortcuts.js";
|
||||
import HeadingStyleOptions from "./options/text_notes/heading_style.js";
|
||||
import TableOfContentsOptions from "./options/text_notes/table_of_contents.js";
|
||||
import HighlightsListOptions from "./options/text_notes/highlights_list.js";
|
||||
import TextAutoReadOnlySizeOptions from "./options/text_notes/text_auto_read_only_size.js";
|
||||
import DateTimeFormatOptions from "./options/text_notes/date_time_format.js";
|
||||
import CodeEditorOptions from "./options/code_notes/code_editor.js";
|
||||
import CodeAutoReadOnlySizeOptions from "./options/code_notes/code_auto_read_only_size.js";
|
||||
import CodeMimeTypesOptions from "./options/code_notes/code_mime_types.js";
|
||||
import ImageOptions from "./options/images/images.js";
|
||||
import SpellcheckOptions from "./options/spellcheck.js";
|
||||
import PasswordOptions from "./options/password/password.js";
|
||||
import ProtectedSessionTimeoutOptions from "./options/password/protected_session_timeout.js";
|
||||
import EtapiOptions from "./options/etapi.js";
|
||||
import BackupOptions from "./options/backup.js";
|
||||
import SyncOptions from "./options/sync.js";
|
||||
import SearchEngineOptions from "./options/other/search_engine.js";
|
||||
import TrayOptions from "./options/other/tray.js";
|
||||
import NoteErasureTimeoutOptions from "./options/other/note_erasure_timeout.js";
|
||||
import RevisionsSnapshotIntervalOptions from "./options/other/revisions_snapshot_interval.js";
|
||||
import RevisionSnapshotsLimitOptions from "./options/other/revision_snapshots_limit.js";
|
||||
import NetworkConnectionsOptions from "./options/other/network_connections.js";
|
||||
import HtmlImportTagsOptions from "./options/other/html_import_tags.js";
|
||||
import AdvancedSyncOptions from "./options/advanced/sync.js";
|
||||
import DatabaseIntegrityCheckOptions from "./options/advanced/database_integrity_check.js";
|
||||
import VacuumDatabaseOptions from "./options/advanced/vacuum_database.js";
|
||||
import DatabaseAnonymizationOptions from "./options/advanced/database_anonymization.js";
|
||||
import BackendLogWidget from "./content/backend_log.js";
|
||||
import AttachmentErasureTimeoutOptions from "./options/other/attachment_erasure_timeout.js";
|
||||
import RibbonOptions from "./options/appearance/ribbon.js";
|
||||
import MultiFactorAuthenticationOptions from './options/multi_factor_authentication.js';
|
||||
import LocalizationOptions from "./options/i18n/i18n.js";
|
||||
import CodeBlockOptions from "./options/text_notes/code_block.js";
|
||||
import EditorOptions from "./options/text_notes/editor.js";
|
||||
import ShareSettingsOptions from "./options/other/share_settings.js";
|
||||
import AiSettingsOptions from "./options/ai_settings.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import LanguageOptions from "./options/i18n/language.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import CodeTheme from "./options/code_notes/code_theme.js";
|
||||
import RelatedSettings from "./options/appearance/related_settings.js";
|
||||
import EditorFeaturesOptions from "./options/text_notes/features.js";
|
||||
|
||||
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
|
||||
<style>
|
||||
.type-contentWidget .note-detail {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-content-widget {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-content-widget-content {
|
||||
padding: 15px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail.full-height .note-detail-content-widget-content {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="note-detail-content-widget-content"></div>
|
||||
</div>`;
|
||||
|
||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
|
||||
|
||||
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (typeof NoteContextAwareWidget)[]> = {
|
||||
_optionsAppearance: [
|
||||
ThemeOptions,
|
||||
FontsOptions,
|
||||
ElectronIntegrationOptions,
|
||||
MaxContentWidthOptions,
|
||||
RibbonOptions
|
||||
],
|
||||
_optionsShortcuts: [
|
||||
KeyboardShortcutsOptions
|
||||
],
|
||||
_optionsTextNotes: [
|
||||
EditorOptions,
|
||||
EditorFeaturesOptions,
|
||||
HeadingStyleOptions,
|
||||
CodeBlockOptions,
|
||||
TableOfContentsOptions,
|
||||
HighlightsListOptions,
|
||||
TextAutoReadOnlySizeOptions,
|
||||
DateTimeFormatOptions
|
||||
],
|
||||
_optionsCodeNotes: [
|
||||
CodeEditorOptions,
|
||||
CodeTheme,
|
||||
CodeMimeTypesOptions,
|
||||
CodeAutoReadOnlySizeOptions
|
||||
],
|
||||
_optionsImages: [
|
||||
ImageOptions
|
||||
],
|
||||
_optionsSpellcheck: [
|
||||
SpellcheckOptions
|
||||
],
|
||||
_optionsPassword: [
|
||||
PasswordOptions,
|
||||
ProtectedSessionTimeoutOptions
|
||||
],
|
||||
_optionsMFA: [MultiFactorAuthenticationOptions],
|
||||
_optionsEtapi: [
|
||||
EtapiOptions
|
||||
],
|
||||
_optionsBackup: [
|
||||
BackupOptions
|
||||
],
|
||||
_optionsSync: [
|
||||
SyncOptions
|
||||
],
|
||||
_optionsAi: [AiSettingsOptions],
|
||||
_optionsOther: [
|
||||
SearchEngineOptions,
|
||||
TrayOptions,
|
||||
NoteErasureTimeoutOptions,
|
||||
AttachmentErasureTimeoutOptions,
|
||||
RevisionsSnapshotIntervalOptions,
|
||||
RevisionSnapshotsLimitOptions,
|
||||
HtmlImportTagsOptions,
|
||||
ShareSettingsOptions,
|
||||
NetworkConnectionsOptions
|
||||
],
|
||||
_optionsLocalization: [
|
||||
LocalizationOptions,
|
||||
LanguageOptions
|
||||
],
|
||||
_optionsAdvanced: [
|
||||
AdvancedSyncOptions,
|
||||
DatabaseIntegrityCheckOptions,
|
||||
DatabaseAnonymizationOptions,
|
||||
VacuumDatabaseOptions
|
||||
],
|
||||
_backendLog: [
|
||||
BackendLogWidget
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Type widget that displays one or more widgets based on the type of note, generally used for options and other interactive notes such as the backend log.
|
||||
*
|
||||
* One important aspect is that, like its parent {@link TypeWidget}, the content widgets don't receive all events by default and they must be manually added
|
||||
* to the propagation list in {@link TypeWidget.handleEventInChildren}.
|
||||
*/
|
||||
export default class ContentWidgetTypeWidget extends TypeWidget {
|
||||
private $content!: JQuery<HTMLElement>;
|
||||
private widget?: BasicWidget;
|
||||
|
||||
static getType() {
|
||||
return "contentWidget";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$content = this.$widget.find(".note-detail-content-widget-content");
|
||||
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote) {
|
||||
this.$content.empty();
|
||||
this.children = [];
|
||||
|
||||
const contentWidgets = [
|
||||
...((CONTENT_WIDGETS as Record<string, typeof NoteContextAwareWidget[]>)[note.noteId]),
|
||||
RelatedSettings
|
||||
];
|
||||
this.$content.toggleClass("options", note.noteId.startsWith("_options"));
|
||||
|
||||
if (contentWidgets) {
|
||||
for (const clazz of contentWidgets) {
|
||||
const widget = new clazz();
|
||||
|
||||
if (this.noteContext) {
|
||||
await widget.handleEvent("setNoteContext", { noteContext: this.noteContext });
|
||||
}
|
||||
this.child(widget);
|
||||
|
||||
this.$content.append(widget.render());
|
||||
this.widget = widget;
|
||||
await widget.refresh();
|
||||
}
|
||||
} else {
|
||||
this.$content.append(t("content_widget.unknown_widget", { id: note.noteId }));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
137
apps/client/src/widgets/type_widgets/content_widget.tsx
Normal file
137
apps/client/src/widgets/type_widgets/content_widget.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import type NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import type { JSX } from "preact/jsx-runtime";
|
||||
import AppearanceSettings from "./options/appearance.jsx";
|
||||
import { disposeReactWidget, renderReactWidget, renderReactWidgetAtElement } from "../react/ReactBasicWidget.jsx";
|
||||
import ImageSettings from "./options/images.jsx";
|
||||
import AdvancedSettings from "./options/advanced.jsx";
|
||||
import InternationalizationOptions from "./options/i18n.jsx";
|
||||
import SyncOptions from "./options/sync.jsx";
|
||||
import EtapiSettings from "./options/etapi.js";
|
||||
import BackupSettings from "./options/backup.js";
|
||||
import SpellcheckSettings from "./options/spellcheck.js";
|
||||
import PasswordSettings from "./options/password.jsx";
|
||||
import ShortcutSettings from "./options/shortcuts.js";
|
||||
import TextNoteSettings from "./options/text_notes.jsx";
|
||||
import CodeNoteSettings from "./options/code_notes.jsx";
|
||||
import OtherSettings from "./options/other.jsx";
|
||||
import BackendLogWidget from "./content/backend_log.js";
|
||||
import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication.js";
|
||||
import AiSettings from "./options/ai_settings.jsx";
|
||||
|
||||
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
|
||||
<style>
|
||||
.type-contentWidget .note-detail {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-content-widget {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-content-widget-content {
|
||||
padding: 15px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail.full-height .note-detail-content-widget-content {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="note-detail-content-widget-content"></div>
|
||||
</div>`;
|
||||
|
||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
|
||||
|
||||
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", ((typeof NoteContextAwareWidget)[] | JSX.Element)> = {
|
||||
_optionsAppearance: <AppearanceSettings />,
|
||||
_optionsShortcuts: <ShortcutSettings />,
|
||||
_optionsTextNotes: <TextNoteSettings />,
|
||||
_optionsCodeNotes: <CodeNoteSettings />,
|
||||
_optionsImages: <ImageSettings />,
|
||||
_optionsSpellcheck: <SpellcheckSettings />,
|
||||
_optionsPassword: <PasswordSettings />,
|
||||
_optionsMFA: <MultiFactorAuthenticationSettings />,
|
||||
_optionsEtapi: <EtapiSettings />,
|
||||
_optionsBackup: <BackupSettings />,
|
||||
_optionsSync: <SyncOptions />,
|
||||
_optionsAi: <AiSettings />,
|
||||
_optionsOther: <OtherSettings />,
|
||||
_optionsLocalization: <InternationalizationOptions />,
|
||||
_optionsAdvanced: <AdvancedSettings />,
|
||||
_backendLog: [
|
||||
BackendLogWidget
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Type widget that displays one or more widgets based on the type of note, generally used for options and other interactive notes such as the backend log.
|
||||
*
|
||||
* One important aspect is that, like its parent {@link TypeWidget}, the content widgets don't receive all events by default and they must be manually added
|
||||
* to the propagation list in {@link TypeWidget.handleEventInChildren}.
|
||||
*/
|
||||
export default class ContentWidgetTypeWidget extends TypeWidget {
|
||||
private $content!: JQuery<HTMLElement>;
|
||||
private widget?: BasicWidget;
|
||||
|
||||
static getType() {
|
||||
return "contentWidget";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$content = this.$widget.find(".note-detail-content-widget-content");
|
||||
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote) {
|
||||
this.$content.empty();
|
||||
this.children = [];
|
||||
|
||||
const contentWidgets = (CONTENT_WIDGETS as Record<string, (typeof NoteContextAwareWidget[] | JSX.Element)>)[note.noteId];
|
||||
this.$content.toggleClass("options", note.noteId.startsWith("_options"));
|
||||
|
||||
// Unknown widget.
|
||||
if (!contentWidgets) {
|
||||
this.$content.append(t("content_widget.unknown_widget", { id: note.noteId }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy widget.
|
||||
if (Array.isArray(contentWidgets)) {
|
||||
for (const clazz of contentWidgets) {
|
||||
const widget = new clazz();
|
||||
|
||||
if (this.noteContext) {
|
||||
await widget.handleEvent("setNoteContext", { noteContext: this.noteContext });
|
||||
}
|
||||
this.child(widget);
|
||||
|
||||
this.$content.append(widget.render());
|
||||
this.widget = widget;
|
||||
await widget.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// React widget.
|
||||
renderReactWidgetAtElement(this, contentWidgets, this.$content[0]);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.noteId) {
|
||||
const contentWidgets = (CONTENT_WIDGETS as Record<string, (typeof NoteContextAwareWidget[] | JSX.Element)>)[this.noteId];
|
||||
if (contentWidgets && !Array.isArray(contentWidgets)) {
|
||||
disposeReactWidget(this.$content[0]);
|
||||
}
|
||||
}
|
||||
|
||||
super.cleanup();
|
||||
}
|
||||
|
||||
}
|
||||
175
apps/client/src/widgets/type_widgets/options/advanced.tsx
Normal file
175
apps/client/src/widgets/type_widgets/options/advanced.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { AnonymizedDbResponse, DatabaseAnonymizeResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import toast from "../../../services/toast";
|
||||
import Button from "../../react/Button";
|
||||
import FormText from "../../react/FormText";
|
||||
import OptionsSection from "./components/OptionsSection"
|
||||
import Column from "../../react/Column";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
export default function AdvancedSettings() {
|
||||
return <>
|
||||
<AdvancedSyncOptions />
|
||||
<DatabaseIntegrityOptions />
|
||||
<DatabaseAnonymizationOptions />
|
||||
<VacuumDatabaseOptions />
|
||||
</>;
|
||||
}
|
||||
|
||||
function AdvancedSyncOptions() {
|
||||
return (
|
||||
<OptionsSection title={t("sync.title")}>
|
||||
<Button
|
||||
text={t("sync.force_full_sync_button")}
|
||||
onClick={async () => {
|
||||
await server.post("sync/force-full-sync");
|
||||
toast.showMessage(t("sync.full_sync_triggered"));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
text={t("sync.fill_entity_changes_button")}
|
||||
onClick={async () => {
|
||||
toast.showMessage(t("sync.filling_entity_changes"));
|
||||
await server.post("sync/fill-entity-changes");
|
||||
toast.showMessage(t("sync.sync_rows_filled_successfully"));
|
||||
}}
|
||||
/>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseIntegrityOptions() {
|
||||
return (
|
||||
<OptionsSection title={t("database_integrity_check.title")}>
|
||||
<FormText>{t("database_integrity_check.description")}</FormText>
|
||||
|
||||
<Button
|
||||
text={t("database_integrity_check.check_button")}
|
||||
onClick={async () => {
|
||||
toast.showMessage(t("database_integrity_check.checking_integrity"));
|
||||
|
||||
const { results } = await server.get<DatabaseCheckIntegrityResponse>("database/check-integrity");
|
||||
|
||||
if (results.length === 1 && results[0].integrity_check === "ok") {
|
||||
toast.showMessage(t("database_integrity_check.integrity_check_succeeded"));
|
||||
} else {
|
||||
toast.showMessage(t("database_integrity_check.integrity_check_failed", { results: JSON.stringify(results, null, 2) }), 15000);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
text={t("consistency_checks.find_and_fix_button")}
|
||||
onClick={async () => {
|
||||
toast.showMessage(t("consistency_checks.finding_and_fixing_message"));
|
||||
await server.post("database/find-and-fix-consistency-issues");
|
||||
toast.showMessage(t("consistency_checks.issues_fixed_message"));
|
||||
}}
|
||||
/>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function DatabaseAnonymizationOptions() {
|
||||
const [ existingAnonymizedDatabases, setExistingAnonymizedDatabases ] = useState<AnonymizedDbResponse[]>([]);
|
||||
|
||||
function refreshAnonymizedDatabase() {
|
||||
server.get<AnonymizedDbResponse[]>("database/anonymized-databases").then(setExistingAnonymizedDatabases);
|
||||
}
|
||||
|
||||
useEffect(refreshAnonymizedDatabase, []);
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("database_anonymization.title")}>
|
||||
<FormText>{t("database_anonymization.choose_anonymization")}</FormText>
|
||||
|
||||
<div className="row">
|
||||
<DatabaseAnonymizationOption
|
||||
title={t("database_anonymization.full_anonymization")}
|
||||
description={t("database_anonymization.full_anonymization_description")}
|
||||
buttonText={t("database_anonymization.save_fully_anonymized_database")}
|
||||
buttonClick={async () => {
|
||||
toast.showMessage(t("database_anonymization.creating_fully_anonymized_database"));
|
||||
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/full");
|
||||
|
||||
if (!resp.success) {
|
||||
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
|
||||
} else {
|
||||
toast.showMessage(t("database_anonymization.successfully_created_fully_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
|
||||
refreshAnonymizedDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DatabaseAnonymizationOption
|
||||
title={t("database_anonymization.light_anonymization")}
|
||||
description={t("database_anonymization.light_anonymization_description")}
|
||||
buttonText={t("database_anonymization.save_lightly_anonymized_database")}
|
||||
buttonClick={async () => {
|
||||
toast.showMessage(t("database_anonymization.creating_lightly_anonymized_database"));
|
||||
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/light");
|
||||
|
||||
if (!resp.success) {
|
||||
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
|
||||
} else {
|
||||
toast.showMessage(t("database_anonymization.successfully_created_lightly_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
|
||||
refreshAnonymizedDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<ExistingAnonymizedDatabases databases={existingAnonymizedDatabases} />
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function DatabaseAnonymizationOption({ title, description, buttonText, buttonClick }: { title: string, description: string, buttonText: string, buttonClick: () => void }) {
|
||||
return (
|
||||
<Column md={6} style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginTop: "1em" }}>
|
||||
<h5>{title}</h5>
|
||||
<FormText>{description}</FormText>
|
||||
<Button text={buttonText} onClick={buttonClick} />
|
||||
</Column>
|
||||
)
|
||||
}
|
||||
|
||||
function ExistingAnonymizedDatabases({ databases }: { databases: AnonymizedDbResponse[] }) {
|
||||
if (!databases.length) {
|
||||
return <FormText>{t("database_anonymization.no_anonymized_database_yet")}</FormText>
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table table-stripped">
|
||||
<thead>
|
||||
<th>{t("database_anonymization.existing_anonymized_databases")}</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{databases.map(({ filePath }) => (
|
||||
<tr>
|
||||
<td>{filePath}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function VacuumDatabaseOptions() {
|
||||
return (
|
||||
<OptionsSection title={t("vacuum_database.title")}>
|
||||
<FormText>{t("vacuum_database.description")}</FormText>
|
||||
|
||||
<Button
|
||||
text={t("vacuum_database.button_text")}
|
||||
onClick={async () => {
|
||||
toast.showMessage(t("vacuum_database.vacuuming_database"));
|
||||
await server.post("database/vacuum-database");
|
||||
toast.showMessage(t("vacuum_database.database_vacuumed"));
|
||||
}}
|
||||
/>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import toastService from "../../../../services/toast.js";
|
||||
import server from "../../../../services/server.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<style>
|
||||
.database-database-anonymization-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.database-database-anonymization-option p {
|
||||
margin-top: .75em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h4>${t("database_anonymization.title")}</h4>
|
||||
|
||||
<div class="row">
|
||||
<p class="form-text">${t("database_anonymization.choose_anonymization")}</p>
|
||||
|
||||
<div class="col-md-6 database-database-anonymization-option">
|
||||
<h5>${t("database_anonymization.full_anonymization")}</h5>
|
||||
|
||||
<p class="form-text">${t("database_anonymization.full_anonymization_description")}</p>
|
||||
<button class="anonymize-full-button btn btn-secondary">${t("database_anonymization.save_fully_anonymized_database")}</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 database-database-anonymization-option">
|
||||
<h5>${t("database_anonymization.light_anonymization")}</h5>
|
||||
|
||||
<p class="form-text">${t("database_anonymization.light_anonymization_description")}</p>
|
||||
|
||||
<button class="anonymize-light-button btn btn-secondary">${t("database_anonymization.save_lightly_anonymized_database")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<table class="existing-anonymized-databases-table table table-stripped">
|
||||
<thead>
|
||||
<th>${t("database_anonymization.existing_anonymized_databases")}</th>
|
||||
</thead>
|
||||
<tbody class="existing-anonymized-databases">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
// TODO: Deduplicate with server
|
||||
interface AnonymizeResponse {
|
||||
success: boolean;
|
||||
anonymizedFilePath: string;
|
||||
}
|
||||
|
||||
interface AnonymizedDbResponse {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export default class DatabaseAnonymizationOptions extends OptionsWidget {
|
||||
|
||||
private $anonymizeFullButton!: JQuery<HTMLElement>;
|
||||
private $anonymizeLightButton!: JQuery<HTMLElement>;
|
||||
private $existingAnonymizedDatabases!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$anonymizeFullButton = this.$widget.find(".anonymize-full-button");
|
||||
this.$anonymizeLightButton = this.$widget.find(".anonymize-light-button");
|
||||
this.$anonymizeFullButton.on("click", async () => {
|
||||
toastService.showMessage(t("database_anonymization.creating_fully_anonymized_database"));
|
||||
|
||||
const resp = await server.post<AnonymizeResponse>("database/anonymize/full");
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(t("database_anonymization.error_creating_anonymized_database"));
|
||||
} else {
|
||||
toastService.showMessage(t("database_anonymization.successfully_created_fully_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
this.$anonymizeLightButton.on("click", async () => {
|
||||
toastService.showMessage(t("database_anonymization.creating_lightly_anonymized_database"));
|
||||
|
||||
const resp = await server.post<AnonymizeResponse>("database/anonymize/light");
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(t("database_anonymization.error_creating_anonymized_database"));
|
||||
} else {
|
||||
toastService.showMessage(t("database_anonymization.successfully_created_lightly_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
this.$existingAnonymizedDatabases = this.$widget.find(".existing-anonymized-databases");
|
||||
}
|
||||
|
||||
optionsLoaded(options: OptionMap) {
|
||||
server.get<AnonymizedDbResponse[]>("database/anonymized-databases").then((anonymizedDatabases) => {
|
||||
this.$existingAnonymizedDatabases.empty();
|
||||
|
||||
if (!anonymizedDatabases.length) {
|
||||
anonymizedDatabases = [{ filePath: t("database_anonymization.no_anonymized_database_yet") }];
|
||||
}
|
||||
|
||||
for (const { filePath } of anonymizedDatabases) {
|
||||
this.$existingAnonymizedDatabases.append($("<tr>").append($("<td>").text(filePath)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import toastService from "../../../../services/toast.js";
|
||||
import server from "../../../../services/server.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("database_integrity_check.title")}</h4>
|
||||
|
||||
<p class="form-text">${t("database_integrity_check.description")}</p>
|
||||
|
||||
<button class="check-integrity-button btn btn-secondary">${t("database_integrity_check.check_button")}</button>
|
||||
<button class="find-and-fix-consistency-issues-button btn btn-secondary">${t("consistency_checks.find_and_fix_button")}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// TODO: Deduplicate with server
|
||||
interface Response {
|
||||
results: {
|
||||
integrity_check: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default class DatabaseIntegrityCheckOptions extends OptionsWidget {
|
||||
|
||||
private $checkIntegrityButton!: JQuery<HTMLElement>;
|
||||
private $findAndFixConsistencyIssuesButton!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$checkIntegrityButton = this.$widget.find(".check-integrity-button");
|
||||
this.$checkIntegrityButton.on("click", async () => {
|
||||
toastService.showMessage(t("database_integrity_check.checking_integrity"));
|
||||
|
||||
const { results } = await server.get<Response>("database/check-integrity");
|
||||
|
||||
if (results.length === 1 && results[0].integrity_check === "ok") {
|
||||
toastService.showMessage(t("database_integrity_check.integrity_check_succeeded"));
|
||||
} else {
|
||||
toastService.showMessage(t("database_integrity_check.integrity_check_failed", { results: JSON.stringify(results, null, 2) }), 15000);
|
||||
}
|
||||
});
|
||||
|
||||
this.$findAndFixConsistencyIssuesButton = this.$widget.find(".find-and-fix-consistency-issues-button");
|
||||
this.$findAndFixConsistencyIssuesButton.on("click", async () => {
|
||||
toastService.showMessage(t("consistency_checks.finding_and_fixing_message"));
|
||||
|
||||
await server.post("database/find-and-fix-consistency-issues");
|
||||
|
||||
toastService.showMessage(t("consistency_checks.issues_fixed_message"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import server from "../../../../services/server.js";
|
||||
import toastService from "../../../../services/toast.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("sync.title")}</h4>
|
||||
<button class="force-full-sync-button btn btn-secondary">${t("sync.force_full_sync_button")}</button>
|
||||
|
||||
<button class="fill-entity-changes-button btn btn-secondary">${t("sync.fill_entity_changes_button")}</button>
|
||||
</div>`;
|
||||
|
||||
export default class AdvancedSyncOptions extends OptionsWidget {
|
||||
|
||||
private $forceFullSyncButton!: JQuery<HTMLElement>;
|
||||
private $fillEntityChangesButton!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$forceFullSyncButton = this.$widget.find(".force-full-sync-button");
|
||||
this.$fillEntityChangesButton = this.$widget.find(".fill-entity-changes-button");
|
||||
this.$forceFullSyncButton.on("click", async () => {
|
||||
await server.post("sync/force-full-sync");
|
||||
|
||||
toastService.showMessage(t("sync.full_sync_triggered"));
|
||||
});
|
||||
|
||||
this.$fillEntityChangesButton.on("click", async () => {
|
||||
toastService.showMessage(t("sync.filling_entity_changes"));
|
||||
|
||||
await server.post("sync/fill-entity-changes");
|
||||
|
||||
toastService.showMessage(t("sync.sync_rows_filled_successfully"));
|
||||
});
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import toastService from "../../../../services/toast.js";
|
||||
import server from "../../../../services/server.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("vacuum_database.title")}</h4>
|
||||
|
||||
<p class="form-text">${t("vacuum_database.description")}</p>
|
||||
|
||||
<button class="vacuum-database-button btn btn-secondary">${t("vacuum_database.button_text")}</button>
|
||||
</div>`;
|
||||
|
||||
export default class VacuumDatabaseOptions extends OptionsWidget {
|
||||
private $vacuumDatabaseButton!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$vacuumDatabaseButton = this.$widget.find(".vacuum-database-button");
|
||||
this.$vacuumDatabaseButton.on("click", async () => {
|
||||
toastService.showMessage(t("vacuum_database.vacuuming_database"));
|
||||
|
||||
await server.post("database/vacuum-database");
|
||||
|
||||
toastService.showMessage(t("vacuum_database.database_vacuumed"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import AiSettingsWidget from './ai_settings/index.js';
|
||||
export default AiSettingsWidget;
|
||||
236
apps/client/src/widgets/type_widgets/options/ai_settings.tsx
Normal file
236
apps/client/src/widgets/type_widgets/options/ai_settings.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../../../services/i18n";
|
||||
import toast from "../../../services/toast";
|
||||
import FormCheckbox from "../../react/FormCheckbox";
|
||||
import FormGroup from "../../react/FormGroup";
|
||||
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import Admonition from "../../react/Admonition";
|
||||
import FormSelect from "../../react/FormSelect";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import type { OllamaModelResponse, OpenAiOrAnthropicModelResponse, OptionNames } from "@triliumnext/commons";
|
||||
import server from "../../../services/server";
|
||||
import Button from "../../react/Button";
|
||||
import FormTextArea from "../../react/FormTextArea";
|
||||
|
||||
export default function AiSettings() {
|
||||
return (
|
||||
<>
|
||||
<EnableAiSettings />
|
||||
<ProviderSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EnableAiSettings() {
|
||||
const [ aiEnabled, setAiEnabled ] = useTriliumOptionBool("aiEnabled");
|
||||
|
||||
return (
|
||||
<>
|
||||
<OptionsSection title={t("ai_llm.title")}>
|
||||
<FormGroup name="ai-enabled" description={t("ai_llm.enable_ai_description")}>
|
||||
<FormCheckbox
|
||||
label={t("ai_llm.enable_ai_features")}
|
||||
currentValue={aiEnabled} onChange={(isEnabled) => {
|
||||
if (isEnabled) {
|
||||
toast.showMessage(t("ai_llm.ai_enabled"));
|
||||
} else {
|
||||
toast.showMessage(t("ai_llm.ai_disabled"));
|
||||
}
|
||||
|
||||
setAiEnabled(isEnabled);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
{aiEnabled && <Admonition type="warning">{t("ai_llm.experimental_warning")}</Admonition>}
|
||||
</OptionsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSettings() {
|
||||
const [ aiSelectedProvider, setAiSelectedProvider ] = useTriliumOption("aiSelectedProvider");
|
||||
const [ aiTemperature, setAiTemperature ] = useTriliumOption("aiTemperature");
|
||||
const [ aiSystemPrompt, setAiSystemPrompt ] = useTriliumOption("aiSystemPrompt");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("ai_llm.provider_configuration")}>
|
||||
<FormGroup name="selected-provider" label={t("ai_llm.selected_provider")} description={t("ai_llm.selected_provider_description")}>
|
||||
<FormSelect
|
||||
values={[
|
||||
{ value: "", text: t("ai_llm.select_provider") },
|
||||
{ value: "openai", text: "OpenAI" },
|
||||
{ value: "anthropic", text: "Anthropic" },
|
||||
{ value: "ollama", text: "Ollama" }
|
||||
]}
|
||||
currentValue={aiSelectedProvider} onChange={setAiSelectedProvider}
|
||||
keyProperty="value" titleProperty="text"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
aiSelectedProvider === "openai" ?
|
||||
<SingleProviderSettings
|
||||
title={t("ai_llm.openai_settings")}
|
||||
apiKeyDescription={t("ai_llm.openai_api_key_description")}
|
||||
baseUrlDescription={t("ai_llm.openai_url_description")}
|
||||
modelDescription={t("ai_llm.openai_model_description")}
|
||||
validationErrorMessage={t("ai_llm.empty_key_warning.openai")}
|
||||
apiKeyOption="openaiApiKey" baseUrlOption="openaiBaseUrl" modelOption="openaiDefaultModel"
|
||||
provider={aiSelectedProvider}
|
||||
/>
|
||||
: aiSelectedProvider === "anthropic" ?
|
||||
<SingleProviderSettings
|
||||
title={t("ai_llm.anthropic_settings")}
|
||||
apiKeyDescription={t("ai_llm.anthropic_api_key_description")}
|
||||
modelDescription={t("ai_llm.anthropic_model_description")}
|
||||
baseUrlDescription={t("ai_llm.anthropic_url_description")}
|
||||
validationErrorMessage={t("ai_llm.empty_key_warning.anthropic")}
|
||||
apiKeyOption="anthropicApiKey" baseUrlOption="anthropicBaseUrl" modelOption="anthropicDefaultModel"
|
||||
provider={aiSelectedProvider}
|
||||
/>
|
||||
: aiSelectedProvider === "ollama" ?
|
||||
<SingleProviderSettings
|
||||
title={t("ai_llm.ollama_settings")}
|
||||
baseUrlDescription={t("ai_llm.ollama_url_description")}
|
||||
modelDescription={t("ai_llm.ollama_model_description")}
|
||||
validationErrorMessage={t("ai_llm.ollama_no_url")}
|
||||
baseUrlOption="ollamaBaseUrl"
|
||||
provider={aiSelectedProvider} modelOption="ollamaDefaultModel"
|
||||
/>
|
||||
:
|
||||
<></>
|
||||
}
|
||||
|
||||
<FormGroup name="ai-temperature" label={t("ai_llm.temperature")} description={t("ai_llm.temperature_description")}>
|
||||
<FormTextBox
|
||||
type="number" min="0" max="2" step="0.1"
|
||||
currentValue={aiTemperature} onChange={setAiTemperature}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup name="system-prompt" label={t("ai_llm.system_prompt")} description={t("ai_llm.system_prompt_description")}>
|
||||
<FormTextArea
|
||||
rows={3}
|
||||
currentValue={aiSystemPrompt} onBlur={setAiSystemPrompt}
|
||||
/>
|
||||
</FormGroup>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
|
||||
interface SingleProviderSettingsProps {
|
||||
provider: string;
|
||||
title: string;
|
||||
apiKeyDescription?: string;
|
||||
baseUrlDescription: string;
|
||||
modelDescription: string;
|
||||
validationErrorMessage: string;
|
||||
apiKeyOption?: OptionNames;
|
||||
baseUrlOption: OptionNames;
|
||||
modelOption: OptionNames;
|
||||
}
|
||||
|
||||
function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDescription, modelDescription, validationErrorMessage, apiKeyOption, baseUrlOption, modelOption }: SingleProviderSettingsProps) {
|
||||
const [ apiKey, setApiKey ] = apiKeyOption ? useTriliumOption(apiKeyOption) : [];
|
||||
const [ baseUrl, setBaseUrl ] = useTriliumOption(baseUrlOption);
|
||||
const isValid = (apiKeyOption ? !!apiKey : !!baseUrl);
|
||||
|
||||
return (
|
||||
<div class="provider-settings">
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5>{title}</h5>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{!isValid && <Admonition type="caution">{validationErrorMessage}</Admonition> }
|
||||
|
||||
{apiKeyOption && (
|
||||
<FormGroup name="api-key" label={t("ai_llm.api_key")} description={apiKeyDescription}>
|
||||
<FormTextBox
|
||||
type="password" autoComplete="off"
|
||||
currentValue={apiKey} onChange={setApiKey}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
<FormGroup name="base-url" label={t("ai_llm.url")} description={baseUrlDescription}>
|
||||
<FormTextBox
|
||||
currentValue={baseUrl ?? "https://api.openai.com/v1"} onChange={setBaseUrl}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{isValid &&
|
||||
<FormGroup name="model" label={t("ai_llm.model")} description={modelDescription}>
|
||||
<ModelSelector provider={provider} baseUrl={baseUrl} modelOption={modelOption} />
|
||||
</FormGroup>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelSelector({ provider, baseUrl, modelOption }: { provider: string; baseUrl: string, modelOption: OptionNames }) {
|
||||
const [ model, setModel ] = useTriliumOption(modelOption);
|
||||
const [ models, setModels ] = useState<{ name: string, id: string }[]>([]);
|
||||
|
||||
const loadProviders = useCallback(async () => {
|
||||
switch (provider) {
|
||||
case "openai":
|
||||
case "anthropic": {
|
||||
try {
|
||||
const response = await server.get<OpenAiOrAnthropicModelResponse>(`llm/providers/${provider}/models?baseUrl=${encodeURIComponent(baseUrl)}`);
|
||||
if (response.success) {
|
||||
setModels(response.chatModels.toSorted((a, b) => a.name.localeCompare(b.name)));
|
||||
} else {
|
||||
toast.showError(t("ai_llm.no_models_found_online"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.showError(t("ai_llm.error_fetching", { error: e }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ollama": {
|
||||
try {
|
||||
const response = await server.get<OllamaModelResponse>(`llm/providers/ollama/models?baseUrl=${encodeURIComponent(baseUrl)}`);
|
||||
if (response.success) {
|
||||
setModels(response.models
|
||||
.map(model => ({
|
||||
name: model.name,
|
||||
id: model.model
|
||||
}))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name)));
|
||||
} else {
|
||||
toast.showError(t("ai_llm.no_models_found_ollama"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.showError(t("ai_llm.error_fetching", { error: e }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProviders();
|
||||
}, [provider]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSelect
|
||||
values={models}
|
||||
keyProperty="id" titleProperty="name"
|
||||
currentValue={model} onChange={setModel}
|
||||
/>
|
||||
|
||||
<Button
|
||||
text={t("ai_llm.refresh_models")}
|
||||
onClick={loadProviders}
|
||||
size="small"
|
||||
style={{ marginTop: "0.5em" }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import { TPL } from "./template.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import type { OptionDefinitions, OptionMap } from "@triliumnext/commons";
|
||||
import server from "../../../../services/server.js";
|
||||
import toastService from "../../../../services/toast.js";
|
||||
import { ProviderService } from "./providers.js";
|
||||
|
||||
export default class AiSettingsWidget extends OptionsWidget {
|
||||
private ollamaModelsRefreshed = false;
|
||||
private openaiModelsRefreshed = false;
|
||||
private anthropicModelsRefreshed = false;
|
||||
private providerService: ProviderService | null = null;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.providerService = new ProviderService(this.$widget);
|
||||
|
||||
// Setup event handlers for options
|
||||
this.setupEventHandlers();
|
||||
|
||||
return this.$widget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to set up a change event handler for an option
|
||||
* @param selector The jQuery selector for the element
|
||||
* @param optionName The name of the option to update
|
||||
* @param validateAfter Whether to run validation after the update
|
||||
* @param isCheckbox Whether the element is a checkbox
|
||||
*/
|
||||
setupChangeHandler(selector: string, optionName: keyof OptionDefinitions, validateAfter: boolean = false, isCheckbox: boolean = false) {
|
||||
if (!this.$widget) return;
|
||||
|
||||
const $element = this.$widget.find(selector);
|
||||
$element.on('change', async () => {
|
||||
let value: string;
|
||||
|
||||
if (isCheckbox) {
|
||||
value = $element.prop('checked') ? 'true' : 'false';
|
||||
} else {
|
||||
value = $element.val() as string;
|
||||
}
|
||||
|
||||
await this.updateOption(optionName, value);
|
||||
|
||||
// Special handling for aiEnabled option
|
||||
if (optionName === 'aiEnabled') {
|
||||
try {
|
||||
const isEnabled = value === 'true';
|
||||
|
||||
if (isEnabled) {
|
||||
toastService.showMessage(t("ai_llm.ai_enabled") || "AI features enabled");
|
||||
} else {
|
||||
toastService.showMessage(t("ai_llm.ai_disabled") || "AI features disabled");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling AI:', error);
|
||||
toastService.showError(t("ai_llm.ai_toggle_error") || "Error toggling AI features");
|
||||
}
|
||||
}
|
||||
|
||||
if (validateAfter) {
|
||||
await this.displayValidationWarnings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up all event handlers for options
|
||||
*/
|
||||
setupEventHandlers() {
|
||||
if (!this.$widget) return;
|
||||
|
||||
// Core AI options
|
||||
this.setupChangeHandler('.ai-enabled', 'aiEnabled', true, true);
|
||||
this.setupChangeHandler('.ai-selected-provider', 'aiSelectedProvider', true);
|
||||
this.setupChangeHandler('.ai-temperature', 'aiTemperature');
|
||||
this.setupChangeHandler('.ai-system-prompt', 'aiSystemPrompt');
|
||||
|
||||
// OpenAI options
|
||||
this.setupChangeHandler('.openai-api-key', 'openaiApiKey', true);
|
||||
this.setupChangeHandler('.openai-base-url', 'openaiBaseUrl', true);
|
||||
this.setupChangeHandler('.openai-default-model', 'openaiDefaultModel');
|
||||
|
||||
// Anthropic options
|
||||
this.setupChangeHandler('.anthropic-api-key', 'anthropicApiKey', true);
|
||||
this.setupChangeHandler('.anthropic-default-model', 'anthropicDefaultModel');
|
||||
this.setupChangeHandler('.anthropic-base-url', 'anthropicBaseUrl');
|
||||
|
||||
// Voyage options
|
||||
this.setupChangeHandler('.voyage-api-key', 'voyageApiKey');
|
||||
|
||||
// Ollama options
|
||||
this.setupChangeHandler('.ollama-base-url', 'ollamaBaseUrl');
|
||||
this.setupChangeHandler('.ollama-default-model', 'ollamaDefaultModel');
|
||||
|
||||
const $refreshModels = this.$widget.find('.refresh-models');
|
||||
$refreshModels.on('click', async () => {
|
||||
this.ollamaModelsRefreshed = await this.providerService?.refreshOllamaModels(true, this.ollamaModelsRefreshed) || false;
|
||||
});
|
||||
|
||||
// Add tab change handler for Ollama tab
|
||||
const $ollamaTab = this.$widget.find('#nav-ollama-tab');
|
||||
$ollamaTab.on('shown.bs.tab', async () => {
|
||||
// Only refresh the models if we haven't done it before
|
||||
this.ollamaModelsRefreshed = await this.providerService?.refreshOllamaModels(false, this.ollamaModelsRefreshed) || false;
|
||||
});
|
||||
|
||||
// OpenAI models refresh button
|
||||
const $refreshOpenAIModels = this.$widget.find('.refresh-openai-models');
|
||||
$refreshOpenAIModels.on('click', async () => {
|
||||
this.openaiModelsRefreshed = await this.providerService?.refreshOpenAIModels(true, this.openaiModelsRefreshed) || false;
|
||||
});
|
||||
|
||||
// Add tab change handler for OpenAI tab
|
||||
const $openaiTab = this.$widget.find('#nav-openai-tab');
|
||||
$openaiTab.on('shown.bs.tab', async () => {
|
||||
// Only refresh the models if we haven't done it before
|
||||
this.openaiModelsRefreshed = await this.providerService?.refreshOpenAIModels(false, this.openaiModelsRefreshed) || false;
|
||||
});
|
||||
|
||||
// Anthropic models refresh button
|
||||
const $refreshAnthropicModels = this.$widget.find('.refresh-anthropic-models');
|
||||
$refreshAnthropicModels.on('click', async () => {
|
||||
this.anthropicModelsRefreshed = await this.providerService?.refreshAnthropicModels(true, this.anthropicModelsRefreshed) || false;
|
||||
});
|
||||
|
||||
// Add tab change handler for Anthropic tab
|
||||
const $anthropicTab = this.$widget.find('#nav-anthropic-tab');
|
||||
$anthropicTab.on('shown.bs.tab', async () => {
|
||||
// Only refresh the models if we haven't done it before
|
||||
this.anthropicModelsRefreshed = await this.providerService?.refreshAnthropicModels(false, this.anthropicModelsRefreshed) || false;
|
||||
});
|
||||
|
||||
|
||||
// Add provider selection change handlers for dynamic settings visibility
|
||||
this.$widget.find('.ai-selected-provider').on('change', async () => {
|
||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
||||
this.$widget.find('.provider-settings').hide();
|
||||
if (selectedProvider) {
|
||||
this.$widget.find(`.${selectedProvider}-provider-settings`).show();
|
||||
// Automatically fetch models for the newly selected provider
|
||||
await this.fetchModelsForProvider(selectedProvider, 'chat');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Add base URL change handlers to trigger model fetching
|
||||
this.$widget.find('.openai-base-url').on('change', async () => {
|
||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
||||
if (selectedProvider === 'openai') {
|
||||
await this.fetchModelsForProvider('openai', 'chat');
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.find('.anthropic-base-url').on('change', async () => {
|
||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
||||
if (selectedProvider === 'anthropic') {
|
||||
await this.fetchModelsForProvider('anthropic', 'chat');
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.find('.ollama-base-url').on('change', async () => {
|
||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
||||
if (selectedProvider === 'ollama') {
|
||||
await this.fetchModelsForProvider('ollama', 'chat');
|
||||
}
|
||||
});
|
||||
|
||||
// Add API key change handlers to trigger model fetching
|
||||
this.$widget.find('.openai-api-key').on('change', async () => {
|
||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
||||
if (selectedProvider === 'openai') {
|
||||
await this.fetchModelsForProvider('openai', 'chat');
|
||||
}
|
||||
});
|
||||
|
||||
this.$widget.find('.anthropic-api-key').on('change', async () => {
|
||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
||||
if (selectedProvider === 'anthropic') {
|
||||
await this.fetchModelsForProvider('anthropic', 'chat');
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Display warnings for validation issues with providers
|
||||
*/
|
||||
async displayValidationWarnings() {
|
||||
if (!this.$widget) return;
|
||||
|
||||
const $warningDiv = this.$widget.find('.provider-validation-warning');
|
||||
|
||||
// Check if AI is enabled
|
||||
const aiEnabled = this.$widget.find('.ai-enabled').prop('checked');
|
||||
if (!aiEnabled) {
|
||||
$warningDiv.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get selected provider
|
||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
||||
|
||||
// Start with experimental warning
|
||||
const allWarnings = [
|
||||
t("ai_llm.experimental_warning")
|
||||
];
|
||||
|
||||
// Check for selected provider configuration
|
||||
const providerWarnings: string[] = [];
|
||||
if (selectedProvider === 'openai') {
|
||||
const openaiApiKey = this.$widget.find('.openai-api-key').val();
|
||||
if (!openaiApiKey) {
|
||||
providerWarnings.push(t("ai_llm.empty_key_warning.openai"));
|
||||
}
|
||||
} else if (selectedProvider === 'anthropic') {
|
||||
const anthropicApiKey = this.$widget.find('.anthropic-api-key').val();
|
||||
if (!anthropicApiKey) {
|
||||
providerWarnings.push(t("ai_llm.empty_key_warning.anthropic"));
|
||||
}
|
||||
} else if (selectedProvider === 'ollama') {
|
||||
const ollamaBaseUrl = this.$widget.find('.ollama-base-url').val();
|
||||
if (!ollamaBaseUrl) {
|
||||
providerWarnings.push(t("ai_llm.ollama_no_url"));
|
||||
}
|
||||
}
|
||||
|
||||
// Add provider warnings to all warnings
|
||||
allWarnings.push(...providerWarnings);
|
||||
|
||||
// Show or hide warnings
|
||||
if (allWarnings.length > 0) {
|
||||
const warningHtml = '<strong>' + t("ai_llm.configuration_warnings") + '</strong><ul>' +
|
||||
allWarnings.map(warning => `<li>${warning}</li>`).join('') + '</ul>';
|
||||
$warningDiv.html(warningHtml).show();
|
||||
} else {
|
||||
$warningDiv.hide();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper to get display name for providers
|
||||
*/
|
||||
getProviderDisplayName(provider: string): string {
|
||||
switch(provider) {
|
||||
case 'openai': return 'OpenAI';
|
||||
case 'anthropic': return 'Anthropic';
|
||||
case 'ollama': return 'Ollama';
|
||||
case 'voyage': return 'Voyage';
|
||||
case 'local': return 'Local';
|
||||
default: return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model dropdown value, adding the option if it doesn't exist
|
||||
*/
|
||||
setModelDropdownValue(selector: string, value: string | undefined) {
|
||||
if (!this.$widget || !value) return;
|
||||
|
||||
const $dropdown = this.$widget.find(selector);
|
||||
|
||||
// Check if the value already exists as an option
|
||||
if ($dropdown.find(`option[value="${value}"]`).length === 0) {
|
||||
// Add the custom value as an option
|
||||
$dropdown.append(`<option value="${value}">${value} (current)</option>`);
|
||||
}
|
||||
|
||||
// Set the value
|
||||
$dropdown.val(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch models for a specific provider and model type
|
||||
*/
|
||||
async fetchModelsForProvider(provider: string, modelType: 'chat') {
|
||||
if (!this.providerService) return;
|
||||
|
||||
try {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
this.openaiModelsRefreshed = await this.providerService.refreshOpenAIModels(false, this.openaiModelsRefreshed);
|
||||
break;
|
||||
case 'anthropic':
|
||||
this.anthropicModelsRefreshed = await this.providerService.refreshAnthropicModels(false, this.anthropicModelsRefreshed);
|
||||
break;
|
||||
case 'ollama':
|
||||
this.ollamaModelsRefreshed = await this.providerService.refreshOllamaModels(false, this.ollamaModelsRefreshed);
|
||||
break;
|
||||
default:
|
||||
console.log(`Model fetching not implemented for provider: ${provider}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching models for ${provider}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider settings visibility based on selected providers
|
||||
*/
|
||||
updateProviderSettingsVisibility() {
|
||||
if (!this.$widget) return;
|
||||
|
||||
// Update AI provider settings visibility
|
||||
const selectedAiProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
||||
this.$widget.find('.provider-settings').hide();
|
||||
if (selectedAiProvider) {
|
||||
this.$widget.find(`.${selectedAiProvider}-provider-settings`).show();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the options have been loaded from the server
|
||||
*/
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
if (!this.$widget) return;
|
||||
|
||||
// AI Options
|
||||
this.$widget.find('.ai-enabled').prop('checked', options.aiEnabled !== 'false');
|
||||
this.$widget.find('.ai-temperature').val(options.aiTemperature || '0.7');
|
||||
this.$widget.find('.ai-system-prompt').val(options.aiSystemPrompt || '');
|
||||
this.$widget.find('.ai-selected-provider').val(options.aiSelectedProvider || 'openai');
|
||||
|
||||
// OpenAI Section
|
||||
this.$widget.find('.openai-api-key').val(options.openaiApiKey || '');
|
||||
this.$widget.find('.openai-base-url').val(options.openaiBaseUrl || 'https://api.openai.com/v1');
|
||||
this.setModelDropdownValue('.openai-default-model', options.openaiDefaultModel);
|
||||
|
||||
// Anthropic Section
|
||||
this.$widget.find('.anthropic-api-key').val(options.anthropicApiKey || '');
|
||||
this.$widget.find('.anthropic-base-url').val(options.anthropicBaseUrl || 'https://api.anthropic.com');
|
||||
this.setModelDropdownValue('.anthropic-default-model', options.anthropicDefaultModel);
|
||||
|
||||
// Voyage Section
|
||||
this.$widget.find('.voyage-api-key').val(options.voyageApiKey || '');
|
||||
|
||||
// Ollama Section
|
||||
this.$widget.find('.ollama-base-url').val(options.ollamaBaseUrl || 'http://localhost:11434');
|
||||
this.setModelDropdownValue('.ollama-default-model', options.ollamaDefaultModel);
|
||||
|
||||
// Show/hide provider settings based on selected providers
|
||||
this.updateProviderSettingsVisibility();
|
||||
|
||||
// Automatically fetch models for currently selected providers
|
||||
const selectedAiProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
||||
|
||||
if (selectedAiProvider) {
|
||||
await this.fetchModelsForProvider(selectedAiProvider, 'chat');
|
||||
}
|
||||
|
||||
// Display validation warnings
|
||||
this.displayValidationWarnings();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// Cleanup method for widget
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import AiSettingsWidget from './ai_settings_widget.js';
|
||||
export default AiSettingsWidget;
|
||||
@@ -1,31 +0,0 @@
|
||||
// Interface for the Ollama model response
|
||||
export interface OllamaModelResponse {
|
||||
success: boolean;
|
||||
models: Array<{
|
||||
name: string;
|
||||
model: string;
|
||||
details?: {
|
||||
family?: string;
|
||||
parameter_size?: string;
|
||||
}
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
export interface OpenAIModelResponse {
|
||||
success: boolean;
|
||||
chatModels: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AnthropicModelResponse {
|
||||
success: boolean;
|
||||
chatModels: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import server from "../../../../services/server.js";
|
||||
import toastService from "../../../../services/toast.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import options from "../../../../services/options.js";
|
||||
import type { OpenAIModelResponse, AnthropicModelResponse, OllamaModelResponse } from "./interfaces.js";
|
||||
|
||||
export class ProviderService {
|
||||
constructor(private $widget: JQuery<HTMLElement>) {
|
||||
// AI provider settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the dropdown has the correct value set, prioritizing:
|
||||
* 1. Current UI value if present
|
||||
* 2. Value from database options if available
|
||||
* 3. Falling back to first option if neither is available
|
||||
*/
|
||||
private ensureSelectedValue($select: JQuery<HTMLElement>, currentValue: string | number | string[] | undefined | null, optionName: string) {
|
||||
if (currentValue) {
|
||||
$select.val(currentValue);
|
||||
// If the value doesn't exist anymore, select the first option
|
||||
if (!$select.val()) {
|
||||
$select.prop('selectedIndex', 0);
|
||||
}
|
||||
} else {
|
||||
// If no current value exists in the dropdown but there's a default in the database
|
||||
const savedModel = options.get(optionName);
|
||||
if (savedModel) {
|
||||
$select.val(savedModel);
|
||||
// If the saved model isn't in the dropdown, select the first option
|
||||
if (!$select.val()) {
|
||||
$select.prop('selectedIndex', 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the list of OpenAI models
|
||||
* @param showLoading Whether to show loading indicators and toasts
|
||||
* @param openaiModelsRefreshed Reference to track if models have been refreshed
|
||||
* @returns Promise that resolves when the refresh is complete
|
||||
*/
|
||||
async refreshOpenAIModels(showLoading: boolean, openaiModelsRefreshed: boolean): Promise<boolean> {
|
||||
if (!this.$widget) return false;
|
||||
|
||||
const $refreshOpenAIModels = this.$widget.find('.refresh-openai-models');
|
||||
|
||||
// If we've already refreshed and we're not forcing a refresh, don't do it again
|
||||
if (openaiModelsRefreshed && !showLoading) {
|
||||
return openaiModelsRefreshed;
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
$refreshOpenAIModels.prop('disabled', true);
|
||||
$refreshOpenAIModels.html(`<i class="spinner-border spinner-border-sm"></i>`);
|
||||
}
|
||||
|
||||
try {
|
||||
const openaiBaseUrl = this.$widget.find('.openai-base-url').val() as string;
|
||||
const response = await server.get<OpenAIModelResponse>(`llm/providers/openai/models?baseUrl=${encodeURIComponent(openaiBaseUrl)}`);
|
||||
|
||||
if (response && response.success) {
|
||||
// Update the chat models dropdown
|
||||
if (response.chatModels?.length > 0) {
|
||||
const $chatModelSelect = this.$widget.find('.openai-default-model');
|
||||
const currentChatValue = $chatModelSelect.val();
|
||||
|
||||
// Clear existing options
|
||||
$chatModelSelect.empty();
|
||||
|
||||
// Sort models by name
|
||||
const sortedChatModels = [...response.chatModels].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add models to the dropdown
|
||||
sortedChatModels.forEach(model => {
|
||||
$chatModelSelect.append(`<option value="${model.id}">${model.name}</option>`);
|
||||
});
|
||||
|
||||
// Try to restore the previously selected value
|
||||
this.ensureSelectedValue($chatModelSelect, currentChatValue, 'openaiDefaultModel');
|
||||
}
|
||||
|
||||
|
||||
if (showLoading) {
|
||||
// Show success message
|
||||
const totalModels = (response.chatModels?.length || 0);
|
||||
toastService.showMessage(`${totalModels} OpenAI models found.`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else if (showLoading) {
|
||||
toastService.showError(`No OpenAI models found. Please check your API key and settings.`);
|
||||
}
|
||||
|
||||
return openaiModelsRefreshed;
|
||||
} catch (e) {
|
||||
console.error(`Error fetching OpenAI models:`, e);
|
||||
if (showLoading) {
|
||||
toastService.showError(`Error fetching OpenAI models: ${e}`);
|
||||
}
|
||||
return openaiModelsRefreshed;
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
$refreshOpenAIModels.prop('disabled', false);
|
||||
$refreshOpenAIModels.html(`<span class="bx bx-refresh"></span>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the list of Anthropic models
|
||||
* @param showLoading Whether to show loading indicators and toasts
|
||||
* @param anthropicModelsRefreshed Reference to track if models have been refreshed
|
||||
* @returns Promise that resolves when the refresh is complete
|
||||
*/
|
||||
async refreshAnthropicModels(showLoading: boolean, anthropicModelsRefreshed: boolean): Promise<boolean> {
|
||||
if (!this.$widget) return false;
|
||||
|
||||
const $refreshAnthropicModels = this.$widget.find('.refresh-anthropic-models');
|
||||
|
||||
// If we've already refreshed and we're not forcing a refresh, don't do it again
|
||||
if (anthropicModelsRefreshed && !showLoading) {
|
||||
return anthropicModelsRefreshed;
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
$refreshAnthropicModels.prop('disabled', true);
|
||||
$refreshAnthropicModels.html(`<i class="spinner-border spinner-border-sm"></i>`);
|
||||
}
|
||||
|
||||
try {
|
||||
const anthropicBaseUrl = this.$widget.find('.anthropic-base-url').val() as string;
|
||||
const response = await server.get<AnthropicModelResponse>(`llm/providers/anthropic/models?baseUrl=${encodeURIComponent(anthropicBaseUrl)}`);
|
||||
|
||||
if (response && response.success) {
|
||||
// Update the chat models dropdown
|
||||
if (response.chatModels?.length > 0) {
|
||||
const $chatModelSelect = this.$widget.find('.anthropic-default-model');
|
||||
const currentChatValue = $chatModelSelect.val();
|
||||
|
||||
// Clear existing options
|
||||
$chatModelSelect.empty();
|
||||
|
||||
// Sort models by name
|
||||
const sortedChatModels = [...response.chatModels].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add models to the dropdown
|
||||
sortedChatModels.forEach(model => {
|
||||
$chatModelSelect.append(`<option value="${model.id}">${model.name}</option>`);
|
||||
});
|
||||
|
||||
// Try to restore the previously selected value
|
||||
this.ensureSelectedValue($chatModelSelect, currentChatValue, 'anthropicDefaultModel');
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
// Show success message
|
||||
const totalModels = (response.chatModels?.length || 0);
|
||||
toastService.showMessage(`${totalModels} Anthropic models found.`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else if (showLoading) {
|
||||
toastService.showError(`No Anthropic models found. Please check your API key and settings.`);
|
||||
}
|
||||
|
||||
return anthropicModelsRefreshed;
|
||||
} catch (e) {
|
||||
console.error(`Error fetching Anthropic models:`, e);
|
||||
if (showLoading) {
|
||||
toastService.showError(`Error fetching Anthropic models: ${e}`);
|
||||
}
|
||||
return anthropicModelsRefreshed;
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
$refreshAnthropicModels.prop('disabled', false);
|
||||
$refreshAnthropicModels.html(`<span class="bx bx-refresh"></span>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the list of Ollama models
|
||||
* @param showLoading Whether to show loading indicators and toasts
|
||||
* @param ollamaModelsRefreshed Reference to track if models have been refreshed
|
||||
* @returns Promise that resolves when the refresh is complete
|
||||
*/
|
||||
async refreshOllamaModels(showLoading: boolean, ollamaModelsRefreshed: boolean): Promise<boolean> {
|
||||
if (!this.$widget) return false;
|
||||
|
||||
const $refreshModels = this.$widget.find('.refresh-models');
|
||||
|
||||
// If we've already refreshed and we're not forcing a refresh, don't do it again
|
||||
if (ollamaModelsRefreshed && !showLoading) {
|
||||
return ollamaModelsRefreshed;
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
$refreshModels.prop('disabled', true);
|
||||
$refreshModels.text(t("ai_llm.refreshing_models"));
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the general Ollama base URL
|
||||
const ollamaBaseUrl = this.$widget.find('.ollama-base-url').val() as string;
|
||||
|
||||
const response = await server.get<OllamaModelResponse>(`llm/providers/ollama/models?baseUrl=${encodeURIComponent(ollamaBaseUrl)}`);
|
||||
|
||||
if (response && response.success && response.models && response.models.length > 0) {
|
||||
// Update the LLM model dropdown
|
||||
const $modelSelect = this.$widget.find('.ollama-default-model');
|
||||
const currentModelValue = $modelSelect.val();
|
||||
|
||||
// Clear existing options
|
||||
$modelSelect.empty();
|
||||
|
||||
// Sort models by name to make them easier to find
|
||||
const sortedModels = [...response.models].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add all models to the dropdown
|
||||
sortedModels.forEach(model => {
|
||||
$modelSelect.append(`<option value="${model.name}">${model.name}</option>`);
|
||||
});
|
||||
|
||||
// Try to restore the previously selected value
|
||||
this.ensureSelectedValue($modelSelect, currentModelValue, 'ollamaDefaultModel');
|
||||
|
||||
if (showLoading) {
|
||||
toastService.showMessage(`${response.models.length} Ollama models found.`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else if (showLoading) {
|
||||
toastService.showError(`No Ollama models found. Please check if Ollama is running.`);
|
||||
}
|
||||
|
||||
return ollamaModelsRefreshed;
|
||||
} catch (e) {
|
||||
console.error(`Error fetching Ollama models:`, e);
|
||||
if (showLoading) {
|
||||
toastService.showError(`Error fetching Ollama models: ${e}`);
|
||||
}
|
||||
return ollamaModelsRefreshed;
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
$refreshModels.prop('disabled', false);
|
||||
$refreshModels.html(`<span class="bx bx-refresh"></span>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
|
||||
export const TPL = `
|
||||
<div class="options-section">
|
||||
<h4>${t("ai_llm.title")}</h4>
|
||||
|
||||
<!-- Add warning alert div -->
|
||||
<div class="provider-validation-warning alert alert-warning" style="display: none;"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="tn-checkbox">
|
||||
<input class="ai-enabled form-check-input" type="checkbox">
|
||||
${t("ai_llm.enable_ai_features")}
|
||||
</label>
|
||||
<div class="form-text">${t("ai_llm.enable_ai_description")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI settings template -->
|
||||
|
||||
<div class="ai-providers-section options-section">
|
||||
<h4>${t("ai_llm.provider_configuration")}</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.selected_provider")}</label>
|
||||
<select class="ai-selected-provider form-control">
|
||||
<option value="">${t("ai_llm.select_provider")}</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
</select>
|
||||
<div class="form-text">${t("ai_llm.selected_provider_description")}</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Provider Settings -->
|
||||
<div class="provider-settings openai-provider-settings" style="display: none;">
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5>${t("ai_llm.openai_settings")}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.api_key")}</label>
|
||||
<input type="password" class="openai-api-key form-control" autocomplete="off" />
|
||||
<div class="form-text">${t("ai_llm.openai_api_key_description")}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.url")}</label>
|
||||
<input type="text" class="openai-base-url form-control" />
|
||||
<div class="form-text">${t("ai_llm.openai_url_description")}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.model")}</label>
|
||||
<select class="openai-default-model form-control">
|
||||
<option value="">${t("ai_llm.select_model")}</option>
|
||||
</select>
|
||||
<div class="form-text">${t("ai_llm.openai_model_description")}</div>
|
||||
<button class="btn btn-sm btn-outline-secondary refresh-openai-models">${t("ai_llm.refresh_models")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic Provider Settings -->
|
||||
<div class="provider-settings anthropic-provider-settings" style="display: none;">
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5>${t("ai_llm.anthropic_settings")}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.api_key")}</label>
|
||||
<input type="password" class="anthropic-api-key form-control" autocomplete="off" />
|
||||
<div class="form-text">${t("ai_llm.anthropic_api_key_description")}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.url")}</label>
|
||||
<input type="text" class="anthropic-base-url form-control" />
|
||||
<div class="form-text">${t("ai_llm.anthropic_url_description")}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.model")}</label>
|
||||
<select class="anthropic-default-model form-control">
|
||||
<option value="">${t("ai_llm.select_model")}</option>
|
||||
</select>
|
||||
<div class="form-text">${t("ai_llm.anthropic_model_description")}</div>
|
||||
<button class="btn btn-sm btn-outline-secondary refresh-anthropic-models">${t("ai_llm.refresh_models")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ollama Provider Settings -->
|
||||
<div class="provider-settings ollama-provider-settings" style="display: none;">
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5>${t("ai_llm.ollama_settings")}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.url")}</label>
|
||||
<input type="text" class="ollama-base-url form-control" />
|
||||
<div class="form-text">${t("ai_llm.ollama_url_description")}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.model")}</label>
|
||||
<select class="ollama-default-model form-control">
|
||||
<option value="">${t("ai_llm.select_model")}</option>
|
||||
</select>
|
||||
<div class="form-text">${t("ai_llm.ollama_model_description")}</div>
|
||||
<button class="btn btn-sm btn-outline-secondary refresh-models"><span class="bx bx-refresh"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.temperature")}</label>
|
||||
<input class="ai-temperature form-control" type="number" min="0" max="2" step="0.1">
|
||||
<div class="form-text">${t("ai_llm.temperature_description")}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>${t("ai_llm.system_prompt")}</label>
|
||||
<textarea class="ai-system-prompt form-control" rows="3"></textarea>
|
||||
<div class="form-text">${t("ai_llm.system_prompt_description")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
`;
|
||||
293
apps/client/src/widgets/type_widgets/options/appearance.tsx
Normal file
293
apps/client/src/widgets/type_widgets/options/appearance.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { isElectron, isMobile, reloadFrontendApp, restartDesktopApp } from "../../../services/utils";
|
||||
import Column from "../../react/Column";
|
||||
import FormRadioGroup from "../../react/FormRadioGroup";
|
||||
import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect";
|
||||
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import server from "../../../services/server";
|
||||
import FormCheckbox from "../../react/FormCheckbox";
|
||||
import FormGroup from "../../react/FormGroup";
|
||||
import { FontFamily, OptionNames } from "@triliumnext/commons";
|
||||
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
|
||||
import FormText from "../../react/FormText";
|
||||
import Button from "../../react/Button";
|
||||
import RelatedSettings from "./components/RelatedSettings";
|
||||
|
||||
const MIN_CONTENT_WIDTH = 640;
|
||||
|
||||
interface Theme {
|
||||
val: string;
|
||||
title: string;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
const BUILTIN_THEMES: Theme[] = [
|
||||
{ val: "next", title: t("theme.triliumnext") },
|
||||
{ val: "next-light", title: t("theme.triliumnext-light") },
|
||||
{ val: "next-dark", title: t("theme.triliumnext-dark") },
|
||||
{ val: "auto", title: t("theme.auto_theme") },
|
||||
{ val: "light", title: t("theme.light_theme") },
|
||||
{ val: "dark", title: t("theme.dark_theme") }
|
||||
]
|
||||
|
||||
interface FontFamilyEntry {
|
||||
value: FontFamily;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface FontGroup {
|
||||
title: string;
|
||||
items: FontFamilyEntry[];
|
||||
}
|
||||
|
||||
const FONT_FAMILIES: FontGroup[] = [
|
||||
{
|
||||
title: t("fonts.generic-fonts"),
|
||||
items: [
|
||||
{ value: "theme", label: t("fonts.theme_defined") },
|
||||
{ value: "system", label: t("fonts.system-default") },
|
||||
{ value: "serif", label: t("fonts.serif") },
|
||||
{ value: "sans-serif", label: t("fonts.sans-serif") },
|
||||
{ value: "monospace", label: t("fonts.monospace") }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("fonts.sans-serif-system-fonts"),
|
||||
items: [{ value: "Arial" }, { value: "Verdana" }, { value: "Helvetica" }, { value: "Tahoma" }, { value: "Trebuchet MS" }, { value: "Microsoft YaHei" }]
|
||||
},
|
||||
{
|
||||
title: t("fonts.serif-system-fonts"),
|
||||
items: [{ value: "Times New Roman" }, { value: "Georgia" }, { value: "Garamond" }]
|
||||
},
|
||||
{
|
||||
title: t("fonts.monospace-system-fonts"),
|
||||
items: [
|
||||
{ value: "Courier New" },
|
||||
{ value: "Brush Script MT" },
|
||||
{ value: "Impact" },
|
||||
{ value: "American Typewriter" },
|
||||
{ value: "Andalé Mono" },
|
||||
{ value: "Lucida Console" },
|
||||
{ value: "Monaco" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("fonts.handwriting-system-fonts"),
|
||||
items: [{ value: "Bradley Hand" }, { value: "Luminari" }, { value: "Comic Sans MS" }]
|
||||
}
|
||||
];
|
||||
|
||||
export default function AppearanceSettings() {
|
||||
const [ overrideThemeFonts ] = useTriliumOption("overrideThemeFonts");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isMobile() && <LayoutOrientation />}
|
||||
<ApplicationTheme />
|
||||
{overrideThemeFonts === "true" && <Fonts />}
|
||||
{isElectron() && <ElectronIntegration /> }
|
||||
<Performance />
|
||||
<MaxContentWidth />
|
||||
<RelatedSettings items={[
|
||||
{
|
||||
title: t("settings_appearance.related_code_blocks"),
|
||||
targetPage: "_optionsTextNotes"
|
||||
},
|
||||
{
|
||||
title: t("settings_appearance.related_code_notes"),
|
||||
targetPage: "_optionsCodeNotes"
|
||||
}
|
||||
]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutOrientation() {
|
||||
const [ layoutOrientation, setLayoutOrientation ] = useTriliumOption("layoutOrientation", true);
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("theme.layout")}>
|
||||
<FormRadioGroup
|
||||
name="layout-orientation"
|
||||
values={[
|
||||
{
|
||||
label: t("theme.layout-vertical-title"),
|
||||
inlineDescription: t("theme.layout-vertical-description"),
|
||||
value: "vertical"
|
||||
},
|
||||
{
|
||||
label: t("theme.layout-horizontal-title"),
|
||||
inlineDescription: t("theme.layout-horizontal-description"),
|
||||
value: "horizontal"
|
||||
}
|
||||
]}
|
||||
currentValue={layoutOrientation} onChange={setLayoutOrientation}
|
||||
/>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplicationTheme() {
|
||||
const [ theme, setTheme ] = useTriliumOption("theme", true);
|
||||
const [ overrideThemeFonts, setOverrideThemeFonts ] = useTriliumOptionBool("overrideThemeFonts");
|
||||
|
||||
const [ themes, setThemes ] = useState<Theme[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
server.get<Theme[]>("options/user-themes").then((userThemes) => {
|
||||
setThemes([
|
||||
...BUILTIN_THEMES,
|
||||
...userThemes
|
||||
])
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("theme.title")}>
|
||||
<div className="row">
|
||||
<FormGroup name="theme" label={t("theme.theme_label")} className="col-md-6" style={{ marginBottom: 0 }}>
|
||||
<FormSelect
|
||||
values={themes} currentValue={theme} onChange={setTheme}
|
||||
keyProperty="val" titleProperty="title"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup className="side-checkbox col-md-6" name="override-theme-fonts">
|
||||
<FormCheckbox
|
||||
label={t("theme.override_theme_fonts_label")}
|
||||
currentValue={overrideThemeFonts} onChange={setOverrideThemeFonts} />
|
||||
</FormGroup>
|
||||
</div>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function Fonts() {
|
||||
return (
|
||||
<OptionsSection title={t("fonts.fonts")}>
|
||||
<Font title={t("fonts.main_font")} fontFamilyOption="mainFontFamily" fontSizeOption="mainFontSize" />
|
||||
<Font title={t("fonts.note_tree_font")} fontFamilyOption="treeFontFamily" fontSizeOption="treeFontSize" />
|
||||
<Font title={t("fonts.note_detail_font")} fontFamilyOption="detailFontFamily" fontSizeOption="detailFontSize" />
|
||||
<Font title={t("fonts.monospace_font")} fontFamilyOption="monospaceFontFamily" fontSizeOption="monospaceFontSize" />
|
||||
|
||||
<FormText>{t("fonts.note_tree_and_detail_font_sizing")}</FormText>
|
||||
<FormText>{t("fonts.not_all_fonts_available")}</FormText>
|
||||
|
||||
<p>
|
||||
{t("fonts.apply_font_changes")} <Button text={t("fonts.reload_frontend")} size="micro" onClick={reloadFrontendApp} />
|
||||
</p>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function Font({ title, fontFamilyOption, fontSizeOption }: { title: string, fontFamilyOption: OptionNames, fontSizeOption: OptionNames }) {
|
||||
const [ fontFamily, setFontFamily ] = useTriliumOption(fontFamilyOption);
|
||||
const [ fontSize, setFontSize ] = useTriliumOption(fontSizeOption);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5>{title}</h5>
|
||||
<div className="row">
|
||||
<FormGroup name="font-family" className="col-md-4" label={t("fonts.font_family")}>
|
||||
<FormSelectWithGroups
|
||||
values={FONT_FAMILIES}
|
||||
currentValue={fontFamily} onChange={setFontFamily}
|
||||
keyProperty="value" titleProperty="label"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup name="font-size" className="col-md-6" label={t("fonts.size")}>
|
||||
<FormTextBoxWithUnit
|
||||
name="tree-font-size"
|
||||
type="number" min={50} max={200} step={10}
|
||||
currentValue={fontSize} onChange={setFontSize}
|
||||
unit={t("units.percentage")}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ElectronIntegration() {
|
||||
const [ zoomFactor, setZoomFactor ] = useTriliumOption("zoomFactor");
|
||||
const [ nativeTitleBarVisible, setNativeTitleBarVisible ] = useTriliumOptionBool("nativeTitleBarVisible");
|
||||
const [ backgroundEffects, setBackgroundEffects ] = useTriliumOptionBool("backgroundEffects");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("electron_integration.desktop-application")}>
|
||||
<FormGroup name="zoom-factor" label={t("electron_integration.zoom-factor")} description={t("zoom_factor.description")}>
|
||||
<FormTextBox
|
||||
type="number"
|
||||
min="0.3" max="2.0" step="0.1"
|
||||
currentValue={zoomFactor} onChange={setZoomFactor}
|
||||
/>
|
||||
</FormGroup>
|
||||
<hr/>
|
||||
|
||||
<FormGroup name="native-title-bar" description={t("electron_integration.native-title-bar-description")}>
|
||||
<FormCheckbox
|
||||
label={t("electron_integration.native-title-bar")}
|
||||
currentValue={nativeTitleBarVisible} onChange={setNativeTitleBarVisible}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup name="background-effects" description={t("electron_integration.background-effects-description")}>
|
||||
<FormCheckbox
|
||||
label={t("electron_integration.background-effects")}
|
||||
currentValue={backgroundEffects} onChange={setBackgroundEffects}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Button text={t("electron_integration.restart-app-button")} onClick={restartDesktopApp} />
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function Performance() {
|
||||
const [ motionEnabled, setMotionEnabled ] = useTriliumOptionBool("motionEnabled");
|
||||
const [ shadowsEnabled, setShadowsEnabled ] = useTriliumOptionBool("shadowsEnabled");
|
||||
|
||||
|
||||
return <OptionsSection title={t("ui-performance.title")}>
|
||||
<FormGroup name="motion-enabled">
|
||||
<FormCheckbox
|
||||
label={t("ui-performance.enable-motion")}
|
||||
currentValue={motionEnabled} onChange={setMotionEnabled}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup name="shadows-enabled">
|
||||
<FormCheckbox
|
||||
label={t("ui-performance.enable-shadows")}
|
||||
currentValue={shadowsEnabled} onChange={setShadowsEnabled}
|
||||
/>
|
||||
</FormGroup>
|
||||
</OptionsSection>
|
||||
}
|
||||
|
||||
|
||||
function MaxContentWidth() {
|
||||
const [ maxContentWidth, setMaxContentWidth ] = useTriliumOption("maxContentWidth");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("max_content_width.title")}>
|
||||
<FormText>{t("max_content_width.default_description")}</FormText>
|
||||
|
||||
<Column md={6}>
|
||||
<FormGroup name="max-content-width" label={t("max_content_width.max_width_label")}>
|
||||
<FormTextBoxWithUnit
|
||||
type="number" min={MIN_CONTENT_WIDTH} step="10"
|
||||
currentValue={maxContentWidth} onChange={setMaxContentWidth}
|
||||
unit={t("max_content_width.max_width_unit")}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Column>
|
||||
|
||||
<p>
|
||||
{t("max_content_width.apply_changes_description")} <Button text={t("max_content_width.reload_button")} size="micro" onClick={reloadFrontendApp} />
|
||||
</p>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import utils from "../../../../services/utils.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("electron_integration.desktop-application")}</h4>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-12">
|
||||
<label for="zoom-factor-select">${t("electron_integration.zoom-factor")}</label>
|
||||
<input id="zoom-factor-select" type="number" class="zoom-factor-select form-control options-number-input" min="0.3" max="2.0" step="0.1"/>
|
||||
<p class="form-text">${t("zoom_factor.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<label class="form-check tn-checkbox">
|
||||
<input type="checkbox" class="native-title-bar form-check-input" />
|
||||
${t("electron_integration.native-title-bar")}
|
||||
</label>
|
||||
<p class="form-text">
|
||||
${t("electron_integration.native-title-bar-description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-check tn-checkbox">
|
||||
<input type="checkbox" class="background-effects form-check-input" />
|
||||
${t("electron_integration.background-effects")}
|
||||
</label>
|
||||
<p class="form-text">
|
||||
${t("electron_integration.background-effects-description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-secondary btn-micro restart-app-button">${t("electron_integration.restart-app-button")}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class ElectronIntegrationOptions extends OptionsWidget {
|
||||
|
||||
private $zoomFactorSelect!: JQuery<HTMLElement>;
|
||||
private $nativeTitleBar!: JQuery<HTMLElement>;
|
||||
private $backgroundEffects!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.$zoomFactorSelect = this.$widget.find(".zoom-factor-select");
|
||||
this.$zoomFactorSelect.on("change", () => {
|
||||
this.triggerCommand("setZoomFactorAndSave", { zoomFactor: String(this.$zoomFactorSelect.val()) });
|
||||
});
|
||||
|
||||
this.$nativeTitleBar = this.$widget.find("input.native-title-bar");
|
||||
this.$nativeTitleBar.on("change", () => this.updateCheckboxOption("nativeTitleBarVisible", this.$nativeTitleBar));
|
||||
|
||||
this.$backgroundEffects = this.$widget.find("input.background-effects");
|
||||
this.$backgroundEffects.on("change", () => this.updateCheckboxOption("backgroundEffects", this.$backgroundEffects));
|
||||
|
||||
const restartAppButton = this.$widget.find(".restart-app-button");
|
||||
restartAppButton.on("click", utils.restartDesktopApp);
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return utils.isElectron();
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
this.$zoomFactorSelect.val(options.zoomFactor);
|
||||
this.setCheckboxState(this.$nativeTitleBar, options.nativeTitleBarVisible);
|
||||
this.setCheckboxState(this.$backgroundEffects, options.backgroundEffects);
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import utils from "../../../../services/utils.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import type { FontFamily, OptionMap, OptionNames } from "@triliumnext/commons";
|
||||
|
||||
interface FontFamilyEntry {
|
||||
value: FontFamily;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface FontGroup {
|
||||
title: string;
|
||||
items: FontFamilyEntry[];
|
||||
}
|
||||
|
||||
const FONT_FAMILIES: FontGroup[] = [
|
||||
{
|
||||
title: t("fonts.generic-fonts"),
|
||||
items: [
|
||||
{ value: "theme", label: t("fonts.theme_defined") },
|
||||
{ value: "system", label: t("fonts.system-default") },
|
||||
{ value: "serif", label: t("fonts.serif") },
|
||||
{ value: "sans-serif", label: t("fonts.sans-serif") },
|
||||
{ value: "monospace", label: t("fonts.monospace") }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("fonts.sans-serif-system-fonts"),
|
||||
items: [{ value: "Arial" }, { value: "Verdana" }, { value: "Helvetica" }, { value: "Tahoma" }, { value: "Trebuchet MS" }, { value: "Microsoft YaHei" }]
|
||||
},
|
||||
{
|
||||
title: t("fonts.serif-system-fonts"),
|
||||
items: [{ value: "Times New Roman" }, { value: "Georgia" }, { value: "Garamond" }]
|
||||
},
|
||||
{
|
||||
title: t("fonts.monospace-system-fonts"),
|
||||
items: [
|
||||
{ value: "Courier New" },
|
||||
{ value: "Brush Script MT" },
|
||||
{ value: "Impact" },
|
||||
{ value: "American Typewriter" },
|
||||
{ value: "Andalé Mono" },
|
||||
{ value: "Lucida Console" },
|
||||
{ value: "Monaco" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("fonts.handwriting-system-fonts"),
|
||||
items: [{ value: "Bradley Hand" }, { value: "Luminari" }, { value: "Comic Sans MS" }]
|
||||
}
|
||||
];
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("fonts.fonts")}</h4>
|
||||
|
||||
<h5>${t("fonts.main_font")}</h5>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-4">
|
||||
<label for="main-font-family">${t("fonts.font_family")}</label>
|
||||
<select id="main-font-family" class="main-font-family form-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<label for="main-font-size">${t("fonts.size")}</label>
|
||||
|
||||
<label class="input-group tn-number-unit-pair main-font-size-input-group">
|
||||
<input id="main-font-size" type="number" class="main-font-size form-control options-number-input" min="50" max="200" step="10"/>
|
||||
<span class="input-group-text">%</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>${t("fonts.note_tree_font")}</h5>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-4">
|
||||
<label for="tree-font-family">${t("fonts.font_family")}</label>
|
||||
<select id="tree-font-family" class="tree-font-family form-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<label for="tree-font-size">${t("fonts.size")}</label>
|
||||
|
||||
<label class="input-group tn-number-unit-pair tree-font-size-input-group">
|
||||
<input id="tree-font-size" type="number" class="tree-font-size form-control options-number-input" min="50" max="200" step="10"/>
|
||||
<span class="input-group-text">%</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>${t("fonts.note_detail_font")}</h5>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-4">
|
||||
<label for="detail-font-family">${t("fonts.font_family")}</label>
|
||||
<select id="detail-font-family" class="detail-font-family form-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<label for="detail-font-size">${t("fonts.size")}</label>
|
||||
|
||||
<label class="input-group tn-number-unit-pair detail-font-size-input-group">
|
||||
<input id="detail-font-size" type="number" class="detail-font-size form-control options-number-input" min="50" max="200" step="10"/>
|
||||
<span class="input-group-text">%</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>${t("fonts.monospace_font")}</h5>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-4">
|
||||
<label for="monospace-font-family">${t("fonts.font_family")}</label>
|
||||
<select id="monospace-font-family" class="monospace-font-family form-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<label for="monospace-font-size">${t("fonts.size")}</label>
|
||||
|
||||
<label class="input-group tn-number-unit-pair monospace-font-size-input-group">
|
||||
<input id="monospace-font-size" type="number" class="monospace-font-size form-control options-number-input" min="50" max="200" step="10"/>
|
||||
<span class="input-group-text">%</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="form-text">${t("fonts.note_tree_and_detail_font_sizing")}</p>
|
||||
|
||||
<p class="form-text">${t("fonts.not_all_fonts_available")}</p>
|
||||
|
||||
<p>
|
||||
${t("fonts.apply_font_changes")}
|
||||
<button class="btn btn-secondary btn-micro reload-frontend-button">${t("fonts.reload_frontend")}</button>
|
||||
</p>
|
||||
</div>`;
|
||||
|
||||
export default class FontsOptions extends OptionsWidget {
|
||||
private $mainFontSize!: JQuery<HTMLElement>;
|
||||
private $mainFontFamily!: JQuery<HTMLElement>;
|
||||
private $treeFontSize!: JQuery<HTMLElement>;
|
||||
private $treeFontFamily!: JQuery<HTMLElement>;
|
||||
private $detailFontSize!: JQuery<HTMLElement>;
|
||||
private $detailFontFamily!: JQuery<HTMLElement>;
|
||||
private $monospaceFontSize!: JQuery<HTMLElement>;
|
||||
private $monospaceFontFamily!: JQuery<HTMLElement>;
|
||||
|
||||
private _isEnabled?: boolean;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.$mainFontSize = this.$widget.find(".main-font-size");
|
||||
this.$mainFontFamily = this.$widget.find(".main-font-family");
|
||||
|
||||
this.$treeFontSize = this.$widget.find(".tree-font-size");
|
||||
this.$treeFontFamily = this.$widget.find(".tree-font-family");
|
||||
|
||||
this.$detailFontSize = this.$widget.find(".detail-font-size");
|
||||
this.$detailFontFamily = this.$widget.find(".detail-font-family");
|
||||
|
||||
this.$monospaceFontSize = this.$widget.find(".monospace-font-size");
|
||||
this.$monospaceFontFamily = this.$widget.find(".monospace-font-family");
|
||||
|
||||
this.$widget.find(".reload-frontend-button").on("click", () => utils.reloadFrontendApp("changes from appearance options"));
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return !!this._isEnabled;
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
this._isEnabled = options.overrideThemeFonts === "true";
|
||||
this.toggleInt(this._isEnabled);
|
||||
if (!this._isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$mainFontSize.val(options.mainFontSize);
|
||||
this.fillFontFamilyOptions(this.$mainFontFamily, options.mainFontFamily);
|
||||
|
||||
this.$treeFontSize.val(options.treeFontSize);
|
||||
this.fillFontFamilyOptions(this.$treeFontFamily, options.treeFontFamily);
|
||||
|
||||
this.$detailFontSize.val(options.detailFontSize);
|
||||
this.fillFontFamilyOptions(this.$detailFontFamily, options.detailFontFamily);
|
||||
|
||||
this.$monospaceFontSize.val(options.monospaceFontSize);
|
||||
this.fillFontFamilyOptions(this.$monospaceFontFamily, options.monospaceFontFamily);
|
||||
|
||||
const optionsToSave: OptionNames[] = ["mainFontFamily", "mainFontSize", "treeFontFamily", "treeFontSize", "detailFontFamily", "detailFontSize", "monospaceFontFamily", "monospaceFontSize"];
|
||||
|
||||
for (const optionName of optionsToSave) {
|
||||
const $el = (this as any)[`$${optionName}`];
|
||||
$el.on("change", () => this.updateOption(optionName, $el.val()));
|
||||
}
|
||||
}
|
||||
|
||||
fillFontFamilyOptions($select: JQuery<HTMLElement>, currentValue: string) {
|
||||
$select.empty();
|
||||
|
||||
for (const { title, items } of Object.values(FONT_FAMILIES)) {
|
||||
const $group = $("<optgroup>").attr("label", title);
|
||||
|
||||
for (const { value, label } of items) {
|
||||
$group.append(
|
||||
$("<option>")
|
||||
.attr("value", value)
|
||||
.prop("selected", value === currentValue)
|
||||
.text(label ?? value)
|
||||
);
|
||||
}
|
||||
|
||||
$select.append($group);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import utils from "../../../../services/utils.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
|
||||
const MIN_VALUE = 640;
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("max_content_width.title")}</h4>
|
||||
|
||||
<p class="form-text">${t("max_content_width.default_description")}</p>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-6">
|
||||
<label for="max-content-width">${t("max_content_width.max_width_label")}</label>
|
||||
<label class="input-group tn-number-unit-pair">
|
||||
<input id="max-content-width" type="number" min="${MIN_VALUE}" step="10" class="max-content-width form-control options-number-input">
|
||||
<span class="input-group-text">${t("max_content_width.max_width_unit")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
${t("max_content_width.apply_changes_description")}
|
||||
<button class="btn btn-secondary btn-micro reload-frontend-button">${t("max_content_width.reload_button")}</button>
|
||||
</p>
|
||||
</div>`;
|
||||
|
||||
export default class MaxContentWidthOptions extends OptionsWidget {
|
||||
|
||||
private $maxContentWidth!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.$maxContentWidth = this.$widget.find(".max-content-width");
|
||||
|
||||
this.$maxContentWidth.on("change", async () => this.updateOption("maxContentWidth", String(this.$maxContentWidth.val())));
|
||||
|
||||
this.$widget.find(".reload-frontend-button").on("click", () => utils.reloadFrontendApp(t("max_content_width.reload_description")));
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
this.$maxContentWidth.val(Math.max(MIN_VALUE, parseInt(options.maxContentWidth, 10)));
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { OptionPages } from "../../content_widget";
|
||||
import OptionsWidget from "../options_widget";
|
||||
|
||||
const TPL = `\
|
||||
<div class="options-section">
|
||||
<h4>Related settings</h4>
|
||||
|
||||
<nav class="related-settings use-tn-links">
|
||||
<li>Color scheme for code blocks in text notes</li>
|
||||
<li>Color scheme for code notes</li>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.related-settings {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`;
|
||||
|
||||
interface RelatedSettingsConfig {
|
||||
items: {
|
||||
title: string;
|
||||
targetPage: OptionPages;
|
||||
}[];
|
||||
}
|
||||
|
||||
const RELATED_SETTINGS: Record<string, RelatedSettingsConfig> = {
|
||||
"_optionsAppearance": {
|
||||
items: [
|
||||
{
|
||||
title: "Color scheme for code blocks in text notes",
|
||||
targetPage: "_optionsTextNotes"
|
||||
},
|
||||
{
|
||||
title: "Color scheme for code notes",
|
||||
targetPage: "_optionsCodeNotes"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default class RelatedSettings extends OptionsWidget {
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
const config = this.noteId && RELATED_SETTINGS[this.noteId];
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $relatedSettings = this.$widget.find(".related-settings");
|
||||
$relatedSettings.empty();
|
||||
for (const item of config.items) {
|
||||
const $item = $("<li>");
|
||||
const $link = $("<a>").text(item.title);
|
||||
|
||||
$item.append($link);
|
||||
$link.attr("href", `#root/_hidden/_options/${item.targetPage}`);
|
||||
$relatedSettings.append($item);
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return (!!this.noteId && this.noteId in RELATED_SETTINGS);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("ribbon.widgets")}</h4>
|
||||
<div>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" class="promoted-attributes-open-in-ribbon form-check-input">
|
||||
${t("ribbon.promoted_attributes_message")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" class="edited-notes-open-in-ribbon form-check-input">
|
||||
${t("ribbon.edited_notes_message")}
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class RibbonOptions extends OptionsWidget {
|
||||
|
||||
private $promotedAttributesOpenInRibbon!: JQuery<HTMLElement>;
|
||||
private $editedNotesOpenInRibbon!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.$promotedAttributesOpenInRibbon = this.$widget.find(".promoted-attributes-open-in-ribbon");
|
||||
this.$promotedAttributesOpenInRibbon.on("change", () => this.updateCheckboxOption("promotedAttributesOpenInRibbon", this.$promotedAttributesOpenInRibbon));
|
||||
|
||||
this.$editedNotesOpenInRibbon = this.$widget.find(".edited-notes-open-in-ribbon");
|
||||
this.$editedNotesOpenInRibbon.on("change", () => this.updateCheckboxOption("editedNotesOpenInRibbon", this.$editedNotesOpenInRibbon));
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
this.setCheckboxState(this.$promotedAttributesOpenInRibbon, options.promotedAttributesOpenInRibbon);
|
||||
this.setCheckboxState(this.$editedNotesOpenInRibbon, options.editedNotesOpenInRibbon);
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import server from "../../../../services/server.js";
|
||||
import utils from "../../../../services/utils.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("theme.layout")}</h4>
|
||||
|
||||
<div class="form-group row">
|
||||
<div>
|
||||
<label class="tn-radio">
|
||||
<input type="radio" name="layout-orientation" value="vertical" />
|
||||
<strong>${t("theme.layout-vertical-title")}</strong>
|
||||
- ${t("theme.layout-vertical-description")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="tn-radio">
|
||||
<input type="radio" name="layout-orientation" value="horizontal" />
|
||||
<strong>${t("theme.layout-horizontal-title")}</strong>
|
||||
- ${t("theme.layout-horizontal-description")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-section">
|
||||
<h4>${t("theme.title")}</h4>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-6">
|
||||
<label for="theme-select">${t("theme.theme_label")}</label>
|
||||
<select id="theme-select" class="theme-select form-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 side-checkbox">
|
||||
<label class="form-check tn-checkbox">
|
||||
<input type="checkbox" class="override-theme-fonts form-check-input">
|
||||
${t("theme.override_theme_fonts_label")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
interface Theme {
|
||||
val: string;
|
||||
title: string;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
export default class ThemeOptions extends OptionsWidget {
|
||||
|
||||
private $themeSelect!: JQuery<HTMLElement>;
|
||||
private $overrideThemeFonts!: JQuery<HTMLElement>;
|
||||
private $layoutOrientation!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$themeSelect = this.$widget.find(".theme-select");
|
||||
this.$overrideThemeFonts = this.$widget.find(".override-theme-fonts");
|
||||
this.$layoutOrientation = this.$widget.find(`input[name="layout-orientation"]`).on("change", async () => {
|
||||
const newLayoutOrientation = String(this.$widget.find(`input[name="layout-orientation"]:checked`).val());
|
||||
await this.updateOption("layoutOrientation", newLayoutOrientation);
|
||||
utils.reloadFrontendApp("layout orientation change");
|
||||
});
|
||||
|
||||
const $layoutOrientationSection = $(this.$widget[0]);
|
||||
$layoutOrientationSection.toggleClass("hidden-ext", utils.isMobile());
|
||||
|
||||
this.$themeSelect.on("change", async () => {
|
||||
const newTheme = this.$themeSelect.val();
|
||||
|
||||
await server.put(`options/theme/${newTheme}`);
|
||||
|
||||
utils.reloadFrontendApp("theme change");
|
||||
});
|
||||
|
||||
this.$overrideThemeFonts.on("change", () => this.updateCheckboxOption("overrideThemeFonts", this.$overrideThemeFonts));
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
const themes: Theme[] = [
|
||||
{ val: "next", title: t("theme.triliumnext") },
|
||||
{ val: "next-light", title: t("theme.triliumnext-light") },
|
||||
{ val: "next-dark", title: t("theme.triliumnext-dark") },
|
||||
{ val: "auto", title: t("theme.auto_theme") },
|
||||
{ val: "light", title: t("theme.light_theme") },
|
||||
{ val: "dark", title: t("theme.dark_theme") }
|
||||
].concat(await server.get<Theme[]>("options/user-themes"));
|
||||
|
||||
this.$themeSelect.empty();
|
||||
|
||||
for (const theme of themes) {
|
||||
this.$themeSelect.append(
|
||||
$("<option>")
|
||||
.attr("value", theme.val)
|
||||
.attr("data-note-id", theme.noteId || "")
|
||||
.text(theme.title)
|
||||
);
|
||||
}
|
||||
|
||||
this.$themeSelect.val(options.theme);
|
||||
|
||||
this.setCheckboxState(this.$overrideThemeFonts, options.overrideThemeFonts);
|
||||
|
||||
this.$widget.find(`input[name="layout-orientation"][value="${options.layoutOrientation}"]`).prop("checked", "true");
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { formatDateTime } from "../../../utils/formatters.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import OptionsWidget from "./options_widget.js";
|
||||
import server from "../../../services/server.js";
|
||||
import toastService from "../../../services/toast.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("backup.automatic_backup")}</h4>
|
||||
|
||||
<p>${t("backup.automatic_backup_description")}</p>
|
||||
|
||||
<ul style="list-style: none">
|
||||
<li>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" class="daily-backup-enabled form-check-input">
|
||||
${t("backup.enable_daily_backup")}
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" class="weekly-backup-enabled form-check-input">
|
||||
${t("backup.enable_weekly_backup")}
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" class="monthly-backup-enabled form-check-input">
|
||||
${t("backup.enable_monthly_backup")}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="form-text">${t("backup.backup_recommendation")}</p>
|
||||
</div>
|
||||
|
||||
<div class="options-section">
|
||||
<h4>${t("backup.backup_now")}</h4>
|
||||
|
||||
<button class="backup-database-button btn btn-secondary">${t("backup.backup_database_now")}</button>
|
||||
</div>
|
||||
|
||||
<div class="options-section">
|
||||
<h4>${t("backup.existing_backups")}</h4>
|
||||
|
||||
<table class="table table-stripped">
|
||||
<colgroup>
|
||||
<col width="33%" />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t("backup.date-and-time")}</th>
|
||||
<th>${t("backup.path")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="existing-backup-list-items">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
// TODO: Deduplicate.
|
||||
interface PostDatabaseResponse {
|
||||
backupFile: string;
|
||||
}
|
||||
|
||||
// TODO: Deduplicate
|
||||
interface Backup {
|
||||
filePath: string;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export default class BackupOptions extends OptionsWidget {
|
||||
|
||||
private $backupDatabaseButton!: JQuery<HTMLElement>;
|
||||
private $dailyBackupEnabled!: JQuery<HTMLElement>;
|
||||
private $weeklyBackupEnabled!: JQuery<HTMLElement>;
|
||||
private $monthlyBackupEnabled!: JQuery<HTMLElement>;
|
||||
private $existingBackupList!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.$backupDatabaseButton = this.$widget.find(".backup-database-button");
|
||||
|
||||
this.$backupDatabaseButton.on("click", async () => {
|
||||
const { backupFile } = await server.post<PostDatabaseResponse>("database/backup-database");
|
||||
|
||||
toastService.showMessage(t("backup.database_backed_up_to", { backupFilePath: backupFile }), 10000);
|
||||
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
this.$dailyBackupEnabled = this.$widget.find(".daily-backup-enabled");
|
||||
this.$weeklyBackupEnabled = this.$widget.find(".weekly-backup-enabled");
|
||||
this.$monthlyBackupEnabled = this.$widget.find(".monthly-backup-enabled");
|
||||
|
||||
this.$dailyBackupEnabled.on("change", () => this.updateCheckboxOption("dailyBackupEnabled", this.$dailyBackupEnabled));
|
||||
|
||||
this.$weeklyBackupEnabled.on("change", () => this.updateCheckboxOption("weeklyBackupEnabled", this.$weeklyBackupEnabled));
|
||||
|
||||
this.$monthlyBackupEnabled.on("change", () => this.updateCheckboxOption("monthlyBackupEnabled", this.$monthlyBackupEnabled));
|
||||
|
||||
this.$existingBackupList = this.$widget.find(".existing-backup-list-items");
|
||||
}
|
||||
|
||||
optionsLoaded(options: OptionMap) {
|
||||
this.setCheckboxState(this.$dailyBackupEnabled, options.dailyBackupEnabled);
|
||||
this.setCheckboxState(this.$weeklyBackupEnabled, options.weeklyBackupEnabled);
|
||||
this.setCheckboxState(this.$monthlyBackupEnabled, options.monthlyBackupEnabled);
|
||||
|
||||
server.get<Backup[]>("database/backups").then((backupFiles) => {
|
||||
this.$existingBackupList.empty();
|
||||
|
||||
if (!backupFiles.length) {
|
||||
this.$existingBackupList.append(
|
||||
$(`
|
||||
<tr>
|
||||
<td class="empty-table-placeholder" colspan="2">${t("backup.no_backup_yet")}</td>
|
||||
</tr>
|
||||
`)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort the backup files by modification date & time in a desceding order
|
||||
backupFiles.sort((a, b) => {
|
||||
if (a.mtime < b.mtime) return 1;
|
||||
if (a.mtime > b.mtime) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const { filePath, mtime } of backupFiles) {
|
||||
this.$existingBackupList.append(
|
||||
$(`
|
||||
<tr>
|
||||
<td>${mtime ? formatDateTime(mtime) : "-"}</td>
|
||||
<td>${filePath}</td>
|
||||
</tr>
|
||||
`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
119
apps/client/src/widgets/type_widgets/options/backup.tsx
Normal file
119
apps/client/src/widgets/type_widgets/options/backup.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { BackupDatabaseNowResponse, DatabaseBackup } from "@triliumnext/commons";
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import toast from "../../../services/toast";
|
||||
import Button from "../../react/Button";
|
||||
import FormCheckbox from "../../react/FormCheckbox";
|
||||
import FormGroup, { FormMultiGroup } from "../../react/FormGroup";
|
||||
import FormText from "../../react/FormText";
|
||||
import { useTriliumOptionBool } from "../../react/hooks";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { formatDateTime } from "../../../utils/formatters";
|
||||
|
||||
export default function BackupSettings() {
|
||||
const [ backups, setBackups ] = useState<DatabaseBackup[]>([]);
|
||||
|
||||
const refreshBackups = useCallback(() => {
|
||||
server.get<DatabaseBackup[]>("database/backups").then((backupFiles) => {
|
||||
// Sort the backup files by modification date & time in a desceding order
|
||||
backupFiles.sort((a, b) => {
|
||||
if (a.mtime < b.mtime) return 1;
|
||||
if (a.mtime > b.mtime) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
setBackups(backupFiles);
|
||||
});
|
||||
}, [ setBackups ]);
|
||||
|
||||
useEffect(refreshBackups, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomaticBackup />
|
||||
<BackupNow refreshCallback={refreshBackups} />
|
||||
<BackupList backups={backups} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomaticBackup() {
|
||||
const [ dailyBackupEnabled, setDailyBackupEnabled ] = useTriliumOptionBool("dailyBackupEnabled");
|
||||
const [ weeklyBackupEnabled, setWeeklyBackupEnabled ] = useTriliumOptionBool("weeklyBackupEnabled");
|
||||
const [ monthlyBackupEnabled, setMonthlyBackupEnabled ] = useTriliumOptionBool("monthlyBackupEnabled");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("backup.automatic_backup")}>
|
||||
<FormMultiGroup label={t("backup.automatic_backup_description")}>
|
||||
<FormCheckbox
|
||||
name="daily-backup-enabled"
|
||||
label={t("backup.enable_daily_backup")}
|
||||
currentValue={dailyBackupEnabled} onChange={setDailyBackupEnabled}
|
||||
/>
|
||||
|
||||
<FormCheckbox
|
||||
name="weekly-backup-enabled"
|
||||
label={t("backup.enable_weekly_backup")}
|
||||
currentValue={weeklyBackupEnabled} onChange={setWeeklyBackupEnabled}
|
||||
/>
|
||||
|
||||
<FormCheckbox
|
||||
name="monthly-backup-enabled"
|
||||
label={t("backup.enable_monthly_backup")}
|
||||
currentValue={monthlyBackupEnabled} onChange={setMonthlyBackupEnabled}
|
||||
/>
|
||||
</FormMultiGroup>
|
||||
|
||||
<FormText>{t("backup.backup_recommendation")}</FormText>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackupNow({ refreshCallback }: { refreshCallback: () => void }) {
|
||||
return (
|
||||
<OptionsSection title={t("backup.backup_now")}>
|
||||
<Button
|
||||
text={t("backup.backup_database_now")}
|
||||
onClick={async () => {
|
||||
const { backupFile } = await server.post<BackupDatabaseNowResponse>("database/backup-database");
|
||||
toast.showMessage(t("backup.database_backed_up_to", { backupFilePath: backupFile }), 10000);
|
||||
refreshCallback();
|
||||
}}
|
||||
/>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
|
||||
return (
|
||||
<OptionsSection title={t("backup.existing_backups")}>
|
||||
<table class="table table-stripped">
|
||||
<colgroup>
|
||||
<col width="33%" />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("backup.date-and-time")}</th>
|
||||
<th>{t("backup.path")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ backups.length > 0 ? (
|
||||
backups.map(({ mtime, filePath }) => (
|
||||
<tr>
|
||||
<td>{mtime ? formatDateTime(mtime) : "-"}</td>
|
||||
<td>{filePath}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="empty-table-placeholder" colspan={2}>{t("backup.no_backup_yet")}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
164
apps/client/src/widgets/type_widgets/options/code_notes.tsx
Normal file
164
apps/client/src/widgets/type_widgets/options/code_notes.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import CodeMirror, { ColorThemes, getThemeById } from "@triliumnext/codemirror";
|
||||
import { t } from "../../../services/i18n";
|
||||
import Column from "../../react/Column";
|
||||
import FormCheckbox from "../../react/FormCheckbox";
|
||||
import FormGroup from "../../react/FormGroup";
|
||||
import FormSelect from "../../react/FormSelect";
|
||||
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import codeNoteSample from "./samples/code_note.txt?raw";
|
||||
import { DEFAULT_PREFIX } from "../abstract_code_type_widget";
|
||||
import { MimeType } from "@triliumnext/commons";
|
||||
import mime_types from "../../../services/mime_types";
|
||||
import CheckboxList from "./components/CheckboxList";
|
||||
import AutoReadOnlySize from "./components/AutoReadOnlySize";
|
||||
|
||||
const SAMPLE_MIME = "application/typescript";
|
||||
|
||||
export default function CodeNoteSettings() {
|
||||
return (
|
||||
<>
|
||||
<Editor />
|
||||
<Appearance />
|
||||
<CodeMimeTypes />
|
||||
<AutoReadOnlySize option="autoReadonlySizeCode" label={t("code_auto_read_only_size.label")} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Editor() {
|
||||
const [ vimKeymapEnabled, setVimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("code-editor-options.title")}>
|
||||
<FormGroup name="vim-keymap-enabled" description={t("vim_key_bindings.enable_vim_keybindings")}>
|
||||
<FormCheckbox
|
||||
label={t("vim_key_bindings.use_vim_keybindings_in_code_notes")}
|
||||
currentValue={vimKeymapEnabled} onChange={setVimKeymapEnabled}
|
||||
/>
|
||||
</FormGroup>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function Appearance() {
|
||||
const [ codeNoteTheme, setCodeNoteTheme ] = useTriliumOption("codeNoteTheme");
|
||||
const [ codeLineWrapEnabled, setCodeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled");
|
||||
|
||||
const themes = useMemo(() => {
|
||||
return ColorThemes.map(({ id, name }) => ({
|
||||
id: "default:" + id,
|
||||
name
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("code_theme.title")}>
|
||||
<div className="row" style={{ marginBottom: "15px" }}>
|
||||
<FormGroup name="color-scheme" label={t("code_theme.color-scheme")} className="col-md-6" style={{ marginBottom: 0 }}>
|
||||
<FormSelect
|
||||
values={themes}
|
||||
keyProperty="id" titleProperty="name"
|
||||
currentValue={codeNoteTheme} onChange={setCodeNoteTheme}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Column className="side-checkbox">
|
||||
<FormCheckbox
|
||||
name="word-wrap"
|
||||
label={t("code_theme.word_wrapping")}
|
||||
currentValue={codeLineWrapEnabled} onChange={setCodeLineWrapEnabled}
|
||||
/>
|
||||
</Column>
|
||||
</div>
|
||||
|
||||
<CodeNotePreview wordWrapping={codeLineWrapEnabled} themeName={codeNoteTheme} />
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeNotePreview({ themeName, wordWrapping }: { themeName: string, wordWrapping: boolean }) {
|
||||
const editorRef = useRef<CodeMirror>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up previous instance.
|
||||
editorRef.current?.destroy();
|
||||
containerRef.current.innerHTML = "";
|
||||
|
||||
// Set up a new instance.
|
||||
const editor = new CodeMirror({
|
||||
parent: containerRef.current
|
||||
});
|
||||
editor.setText(codeNoteSample);
|
||||
editor.setMimeType(SAMPLE_MIME);
|
||||
editorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.setLineWrapping(wordWrapping);
|
||||
}, [ wordWrapping ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (themeName?.startsWith(DEFAULT_PREFIX)) {
|
||||
const theme = getThemeById(themeName.substring(DEFAULT_PREFIX.length));
|
||||
if (theme) {
|
||||
editorRef.current?.setTheme(theme);
|
||||
}
|
||||
}
|
||||
}, [ themeName ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="note-detail-readonly-code-content"
|
||||
style={{ margin: 0, height: "200px" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeMimeTypes() {
|
||||
const [ codeNotesMimeTypes, setCodeNotesMimeTypes ] = useTriliumOptionJson<string[]>("codeNotesMimeTypes");
|
||||
const sectionStyle = useMemo(() => ({ marginBottom: "1em", breakInside: "avoid-column" }), []);
|
||||
const groupedMimeTypes: Record<string, MimeType[]> = useMemo(() => {
|
||||
mime_types.loadMimeTypes();
|
||||
|
||||
const ungroupedMimeTypes = Array.from(mime_types.getMimeTypes());
|
||||
const plainTextMimeType = ungroupedMimeTypes.shift();
|
||||
const result: Record<string, MimeType[]> = {};
|
||||
ungroupedMimeTypes.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
result[""] = [ plainTextMimeType! ];
|
||||
for (const mimeType of ungroupedMimeTypes) {
|
||||
const initial = mimeType.title.charAt(0).toUpperCase();
|
||||
if (!result[initial]) {
|
||||
result[initial] = [];
|
||||
}
|
||||
result[initial].push(mimeType);
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("code_mime_types.title")}>
|
||||
<ul class="options-mime-types" style={{ listStyleType: "none", columnWidth: "250px" }}>
|
||||
{Object.entries(groupedMimeTypes).map(([ initial, mimeTypes ]) => (
|
||||
<section style={sectionStyle}>
|
||||
{ initial && <h5>{initial}</h5> }
|
||||
<CheckboxList
|
||||
values={mimeTypes}
|
||||
keyProperty="mime" titleProperty="title"
|
||||
currentValue={codeNotesMimeTypes} onChange={setCodeNotesMimeTypes}
|
||||
columnWidth="inherit"
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</ul>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("code_auto_read_only_size.title")}</h4>
|
||||
|
||||
<p class="form-text">${t("code_auto_read_only_size.description")}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="auto-readonly-size-code">${t("code_auto_read_only_size.label")}</label>
|
||||
<label class="input-group tn-number-unit-pair">
|
||||
<input id="auto-readonly-size-code" class="auto-readonly-size-code form-control options-number-input" type="number" min="0">
|
||||
<span class="input-group-text">${t("code_auto_read_only_size.unit")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class CodeAutoReadOnlySizeOptions extends OptionsWidget {
|
||||
|
||||
private $autoReadonlySizeCode!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$autoReadonlySizeCode = this.$widget.find(".auto-readonly-size-code");
|
||||
this.$autoReadonlySizeCode.on("change", () => this.updateOption("autoReadonlySizeCode", this.$autoReadonlySizeCode.val()));
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
this.$autoReadonlySizeCode.val(options.autoReadonlySizeCode);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("code-editor-options.title")}</h4>
|
||||
<label class="tn-checkbox">
|
||||
<input type="checkbox" class="vim-keymap-enabled form-check-input">
|
||||
${t("vim_key_bindings.use_vim_keybindings_in_code_notes")}
|
||||
</label>
|
||||
<p class="form-text">${t("vim_key_bindings.enable_vim_keybindings")}</p>
|
||||
</div>`;
|
||||
|
||||
export default class CodeEditorOptions extends OptionsWidget {
|
||||
|
||||
private $vimKeymapEnabled!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$vimKeymapEnabled = this.$widget.find(".vim-keymap-enabled");
|
||||
this.$vimKeymapEnabled.on("change", () => this.updateCheckboxOption("vimKeymapEnabled", this.$vimKeymapEnabled));
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
this.setCheckboxState(this.$vimKeymapEnabled, options.vimKeymapEnabled);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { t } from "../../../../services/i18n.js";
|
||||
import OptionsWidget from "../options_widget.js";
|
||||
import mimeTypesService from "../../../../services/mime_types.js";
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("code_mime_types.title")}</h4>
|
||||
|
||||
<ul class="options-mime-types" style="list-style-type: none;"></ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.options-mime-types section,
|
||||
.options-mime-types > li:first-of-type {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
let idCtr = 1; // global, since this can be shown in multiple dialogs
|
||||
|
||||
interface MimeType {
|
||||
title: string;
|
||||
mime: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
type GroupedMimes = Record<string, MimeType[]>;
|
||||
|
||||
function groupMimeTypesAlphabetically(ungroupedMimeTypes: MimeType[]) {
|
||||
const result: GroupedMimes = {};
|
||||
ungroupedMimeTypes = ungroupedMimeTypes.toSorted((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
for (const mimeType of ungroupedMimeTypes) {
|
||||
const initial = mimeType.title.charAt(0).toUpperCase();
|
||||
if (!result[initial]) {
|
||||
result[initial] = [];
|
||||
}
|
||||
result[initial].push(mimeType);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default class CodeMimeTypesOptions extends OptionsWidget {
|
||||
|
||||
private $mimeTypes!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$mimeTypes = this.$widget.find(".options-mime-types");
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
this.$mimeTypes.empty();
|
||||
mimeTypesService.loadMimeTypes();
|
||||
|
||||
const ungroupedMimeTypes = Array.from(mimeTypesService.getMimeTypes());
|
||||
const plainTextMimeType = ungroupedMimeTypes.shift();
|
||||
const groupedMimeTypes = groupMimeTypesAlphabetically(ungroupedMimeTypes);
|
||||
|
||||
// Plain text is displayed at the top intentionally.
|
||||
if (plainTextMimeType) {
|
||||
const $plainEl = this.#buildSelectionForMimeType(plainTextMimeType);
|
||||
$plainEl.find("input").attr("disabled", "");
|
||||
this.$mimeTypes.append($plainEl);
|
||||
}
|
||||
|
||||
for (const [initial, mimeTypes] of Object.entries(groupedMimeTypes)) {
|
||||
const $section = $("<section>");
|
||||
$section.append($("<h5>").text(initial));
|
||||
|
||||
for (const mimeType of mimeTypes) {
|
||||
$section.append(this.#buildSelectionForMimeType(mimeType));
|
||||
}
|
||||
|
||||
this.$mimeTypes.append($section);
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
const enabledMimeTypes: string[] = [];
|
||||
|
||||
this.$mimeTypes.find("input:checked").each((i, el) => {
|
||||
const mimeType = this.$widget.find(el).attr("data-mime-type");
|
||||
if (mimeType) {
|
||||
enabledMimeTypes.push(mimeType);
|
||||
}
|
||||
});
|
||||
|
||||
await this.updateOption("codeNotesMimeTypes", JSON.stringify(enabledMimeTypes));
|
||||
}
|
||||
|
||||
#buildSelectionForMimeType(mimeType: MimeType) {
|
||||
const id = "code-mime-type-" + idCtr++;
|
||||
|
||||
const checkbox = $(`<label class="tn-checkbox">`)
|
||||
.append($('<input type="checkbox" class="form-check-input">').attr("id", id).attr("data-mime-type", mimeType.mime).prop("checked", mimeType.enabled))
|
||||
.on("change", () => this.save())
|
||||
.append(mimeType.title);
|
||||
|
||||
return $("<li>").append(checkbox);
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import type { OptionMap } from "@triliumnext/commons";
|
||||
import OptionsWidget from "../options_widget";
|
||||
import server from "../../../../services/server";
|
||||
import CodeMirror, { getThemeById } from "@triliumnext/codemirror";
|
||||
import { DEFAULT_PREFIX } from "../../abstract_code_type_widget";
|
||||
import { t } from "../../../../services/i18n";
|
||||
import { ColorThemes } from "@triliumnext/codemirror";
|
||||
|
||||
// TODO: Deduplicate
|
||||
interface Theme {
|
||||
title: string;
|
||||
val: string;
|
||||
}
|
||||
|
||||
type Response = Theme[];
|
||||
|
||||
const SAMPLE_MIME = "application/typescript";
|
||||
const SAMPLE_CODE = `\
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewUpdate, type EditorViewConfig } from "@codemirror/view";
|
||||
import { defaultHighlightStyle, StreamLanguage, syntaxHighlighting, indentUnit, bracketMatching, foldGutter } from "@codemirror/language";
|
||||
import { Compartment, EditorState, type Extension } from "@codemirror/state";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
import { vim } from "@replit/codemirror-vim";
|
||||
import byMimeType from "./syntax_highlighting.js";
|
||||
import smartIndentWithTab from "./extensions/custom_tab.js";
|
||||
import type { ThemeDefinition } from "./color_themes.js";
|
||||
|
||||
export { default as ColorThemes, type ThemeDefinition, getThemeById } from "./color_themes.js";
|
||||
|
||||
type ContentChangedListener = () => void;
|
||||
|
||||
export interface EditorConfig {
|
||||
parent: HTMLElement;
|
||||
placeholder?: string;
|
||||
lineWrapping?: boolean;
|
||||
vimKeybindings?: boolean;
|
||||
readOnly?: boolean;
|
||||
onContentChanged?: ContentChangedListener;
|
||||
}
|
||||
|
||||
export default class CodeMirror extends EditorView {
|
||||
|
||||
private config: EditorConfig;
|
||||
private languageCompartment: Compartment;
|
||||
private historyCompartment: Compartment;
|
||||
private themeCompartment: Compartment;
|
||||
|
||||
constructor(config: EditorConfig) {
|
||||
const languageCompartment = new Compartment();
|
||||
const historyCompartment = new Compartment();
|
||||
const themeCompartment = new Compartment();
|
||||
|
||||
let extensions: Extension[] = [];
|
||||
|
||||
if (config.vimKeybindings) {
|
||||
extensions.push(vim());
|
||||
}
|
||||
|
||||
extensions = [
|
||||
...extensions,
|
||||
languageCompartment.of([]),
|
||||
themeCompartment.of([
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true })
|
||||
]),
|
||||
highlightActiveLine(),
|
||||
highlightSelectionMatches(),
|
||||
bracketMatching(),
|
||||
lineNumbers(),
|
||||
foldGutter(),
|
||||
indentUnit.of(" ".repeat(4)),
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...smartIndentWithTab
|
||||
])
|
||||
]
|
||||
|
||||
super({
|
||||
parent: config.parent,
|
||||
extensions
|
||||
});
|
||||
}
|
||||
}`;
|
||||
|
||||
const TPL = /*html*/`\
|
||||
<div class="options-section">
|
||||
<h4>${t("code_theme.title")}</h4>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-6">
|
||||
<label for="color-theme">${t("code_theme.color-scheme")}</label>
|
||||
<select id="color-theme" class="theme-select form-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 side-checkbox">
|
||||
<label class="form-check tn-checkbox">
|
||||
<input type="checkbox" class="word-wrap form-check-input" />
|
||||
${t("code_theme.word_wrapping")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note-detail-readonly-code-content">
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.options-section .note-detail-readonly-code-content {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.options-section .note-detail-readonly-code-content .cm-editor {
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default class CodeTheme extends OptionsWidget {
|
||||
|
||||
private $themeSelect!: JQuery<HTMLElement>;
|
||||
private $sampleEl!: JQuery<HTMLElement>;
|
||||
private $lineWrapEnabled!: JQuery<HTMLElement>;
|
||||
private editor?: CodeMirror;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.$themeSelect = this.$widget.find(".theme-select");
|
||||
this.$themeSelect.on("change", async () => {
|
||||
const newTheme = String(this.$themeSelect.val());
|
||||
await server.put(`options/codeNoteTheme/${newTheme}`);
|
||||
});
|
||||
|
||||
// Populate the list of themes.
|
||||
for (const theme of ColorThemes) {
|
||||
const option = $("<option>")
|
||||
.attr("value", `default:${theme.id}`)
|
||||
.text(theme.name);
|
||||
this.$themeSelect.append(option);
|
||||
}
|
||||
|
||||
this.$sampleEl = this.$widget.find(".note-detail-readonly-code-content");
|
||||
this.$lineWrapEnabled = this.$widget.find(".word-wrap");
|
||||
this.$lineWrapEnabled.on("change", () => this.updateCheckboxOption("codeLineWrapEnabled", this.$lineWrapEnabled));
|
||||
}
|
||||
|
||||
async #setupPreview(options: OptionMap) {
|
||||
if (!this.editor) {
|
||||
this.editor = new CodeMirror({
|
||||
parent: this.$sampleEl[0],
|
||||
});
|
||||
}
|
||||
this.editor.setText(SAMPLE_CODE);
|
||||
this.editor.setMimeType(SAMPLE_MIME);
|
||||
this.editor.setLineWrapping(options.codeLineWrapEnabled === "true");
|
||||
|
||||
// Load the theme.
|
||||
const themeId = options.codeNoteTheme;
|
||||
if (themeId?.startsWith(DEFAULT_PREFIX)) {
|
||||
const theme = getThemeById(themeId.substring(DEFAULT_PREFIX.length));
|
||||
if (theme) {
|
||||
await this.editor.setTheme(theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
this.$themeSelect.val(options.codeNoteTheme);
|
||||
this.#setupPreview(options);
|
||||
this.setCheckboxState(this.$lineWrapEnabled, options.codeLineWrapEnabled);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { OptionNames } from "@triliumnext/commons";
|
||||
import FormText from "../../../react/FormText";
|
||||
import { FormTextBoxWithUnit } from "../../../react/FormTextBox";
|
||||
import OptionsSection from "./OptionsSection";
|
||||
import { useTriliumOption } from "../../../react/hooks";
|
||||
import { t } from "../../../../services/i18n";
|
||||
import FormGroup from "../../../react/FormGroup";
|
||||
|
||||
interface AutoReadOnlySizeProps {
|
||||
label: string;
|
||||
option: OptionNames;
|
||||
}
|
||||
|
||||
export default function AutoReadOnlySize({ label, option }: AutoReadOnlySizeProps) {
|
||||
const [ autoReadonlyOpt, setAutoReadonlyOpt ] = useTriliumOption(option);
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("text_auto_read_only_size.title")}>
|
||||
<FormText>{t("text_auto_read_only_size.description")}</FormText>
|
||||
|
||||
<FormGroup name="auto-readonly-size-text" label={label}>
|
||||
<FormTextBoxWithUnit
|
||||
type="number" min={0}
|
||||
unit={t("text_auto_read_only_size.unit")}
|
||||
currentValue={autoReadonlyOpt} onChange={setAutoReadonlyOpt}
|
||||
/>
|
||||
</FormGroup>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
interface CheckboxListProps<T> {
|
||||
values: T[];
|
||||
keyProperty: keyof T;
|
||||
titleProperty?: keyof T;
|
||||
currentValue: string[];
|
||||
onChange: (newValues: string[]) => void;
|
||||
columnWidth?: string;
|
||||
}
|
||||
|
||||
export default function CheckboxList<T>({ values, keyProperty, titleProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
|
||||
function toggleValue(value: string) {
|
||||
if (currentValue.includes(value)) {
|
||||
// Already there, needs removing.
|
||||
onChange(currentValue.filter(v => v !== value));
|
||||
} else {
|
||||
// Not there, needs adding.
|
||||
onChange([ ...currentValue, value ]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ul style={{ listStyleType: "none", marginBottom: 0, columnWidth: columnWidth ?? "400px" }}>
|
||||
{values.map(value => (
|
||||
<li>
|
||||
<label className="tn-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
value={String(value[keyProperty])}
|
||||
checked={currentValue.includes(String(value[keyProperty]))}
|
||||
onChange={e => toggleValue((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{String(value[titleProperty ?? keyProperty] ?? value[keyProperty])}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.option-row {
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
|
||||
.option-row > label {
|
||||
width: 40%;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.option-row > select {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.option-row:last-of-type {
|
||||
border-bottom: unset;
|
||||
}
|
||||
|
||||
.option-row.centered {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cloneElement, VNode } from "preact";
|
||||
import "./OptionsRow.css";
|
||||
import { useUniqueName } from "../../../react/hooks";
|
||||
|
||||
interface OptionsRowProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
children: VNode;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
export default function OptionsRow({ name, label, children, centered }: OptionsRowProps) {
|
||||
const id = useUniqueName(name);
|
||||
const childWithId = cloneElement(children, { id });
|
||||
|
||||
return (
|
||||
<div className={`option-row ${centered ? "centered" : ""}`}>
|
||||
{label && <label for={id}>{label}</label>}
|
||||
{childWithId}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user