mirror of
https://github.com/zadam/trilium.git
synced 2025-10-26 07:46:30 +01:00
Compare commits
516 Commits
release/v0
...
feat/llm-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2ace41b0 | ||
|
|
778f13e2e6 | ||
|
|
bb3d0f0319 | ||
|
|
cec627a744 | ||
|
|
2958ae4587 | ||
|
|
8da904cf55 | ||
|
|
b37d9b4b3d | ||
|
|
ac415c1007 | ||
|
|
d38ca72e08 | ||
|
|
16622f43e3 | ||
|
|
f89c202fcc | ||
|
|
97ec882528 | ||
|
|
a1e596b81b | ||
|
|
3db145b6e6 | ||
|
|
0d898385f6 | ||
|
|
89fcfabd3c | ||
|
|
eeeecb3988 | ||
|
|
28ababcbb9 | ||
|
|
f382943af3 | ||
|
|
fa38332a6c | ||
|
|
5a58fcde96 | ||
|
|
62d048433b | ||
|
|
db4ba53449 | ||
|
|
da20916767 | ||
|
|
b1e12182ce | ||
|
|
80b2061935 | ||
|
|
8ce92f8c93 | ||
|
|
05cd8cb547 | ||
|
|
6e7d0bc51b | ||
|
|
b9aede23e6 | ||
|
|
1d52988826 | ||
|
|
ebe29f41f9 | ||
|
|
598591a2da | ||
|
|
32c2860b68 | ||
|
|
3e1f74ae93 | ||
|
|
81a8908b98 | ||
|
|
892dfe2340 | ||
|
|
fd175eb8a8 | ||
|
|
c98f6d96d5 | ||
|
|
35b628e799 | ||
|
|
49b79c016d | ||
|
|
25a9a8a724 | ||
|
|
313a61ec48 | ||
|
|
a2eab03ee2 | ||
|
|
a563b1c9a0 | ||
|
|
20018b9c21 | ||
|
|
0a9bd5f6d1 | ||
|
|
911fee0213 | ||
|
|
ffe4b53eee | ||
|
|
cd5a68d230 | ||
|
|
95a2a69e0a | ||
|
|
360b5d6de4 | ||
|
|
bf50883e40 | ||
|
|
8e04690568 | ||
|
|
ae0af8b9c7 | ||
|
|
a03a0f8a75 | ||
|
|
f0f27a9065 | ||
|
|
181d5ee36a | ||
|
|
2758a230ac | ||
|
|
a46d32ed75 | ||
|
|
b2bcae8b74 | ||
|
|
49d662afba | ||
|
|
2a27666c53 | ||
|
|
f2d45cb780 | ||
|
|
c4b91c9777 | ||
|
|
fa06f56f5d | ||
|
|
519b962af3 | ||
|
|
31e6ac2349 | ||
|
|
ed3ba2745f | ||
|
|
f5b7648d6d | ||
|
|
2d537b82f6 | ||
|
|
073354fe04 | ||
|
|
165d093928 | ||
|
|
e8cf3f4a10 | ||
|
|
c36b00994b | ||
|
|
76b856bfe5 | ||
|
|
7b084035a3 | ||
|
|
59fbdaa879 | ||
|
|
1046321117 | ||
|
|
00fc92764b | ||
|
|
dea8bc307e | ||
|
|
18a4fbaa4b | ||
|
|
3efc4b13d5 | ||
|
|
952456a69c | ||
|
|
bde8e17fe6 | ||
|
|
9023ba1d0a | ||
|
|
61f9a86685 | ||
|
|
5520cfed5d | ||
|
|
43df984732 | ||
|
|
3f398c1a00 | ||
|
|
ad35e3b48f | ||
|
|
73ee44e177 | ||
|
|
18414cd155 | ||
|
|
652d78ac68 | ||
|
|
9a3ab05d73 | ||
|
|
fe238b8afd | ||
|
|
94492c7535 | ||
|
|
47caf970a1 | ||
|
|
3e75ab39c2 | ||
|
|
72aacdbf6f | ||
|
|
5461dafe02 | ||
|
|
30f9f66b8b | ||
|
|
19de803142 | ||
|
|
11b247fe07 | ||
|
|
faa40494d8 | ||
|
|
796802aea0 | ||
|
|
06af5cf6d5 | ||
|
|
81a99c1e44 | ||
|
|
1b384f35d2 | ||
|
|
c1259f2ea2 | ||
|
|
92d9c82d97 | ||
|
|
064f0ef921 | ||
|
|
e9a9b462d4 | ||
|
|
98888d5f1d | ||
|
|
6a2a096348 | ||
|
|
bf34ef2009 | ||
|
|
9cddb9ac1d | ||
|
|
d72d3db2a0 | ||
|
|
14b3bea203 | ||
|
|
05c26d17d3 | ||
|
|
51360d855a | ||
|
|
ae7d03e3c7 | ||
|
|
87e1ce64d1 | ||
|
|
f9c7c5637b | ||
|
|
5d55b0b0a8 | ||
|
|
b2d7fbbcad | ||
|
|
fbc6734e08 | ||
|
|
a83172390f | ||
|
|
4b1fd5e4a0 | ||
|
|
51495b282f | ||
|
|
b645d21fcd | ||
|
|
8f99ce7d14 | ||
|
|
6eb650bb22 | ||
|
|
a7f5702221 | ||
|
|
efeb9b90ca | ||
|
|
3361a2e4ab | ||
|
|
425ade5212 | ||
|
|
384ab1d1f3 | ||
|
|
70b1a37285 | ||
|
|
61a878e2a0 | ||
|
|
319cb8384c | ||
|
|
dd7ee05388 | ||
|
|
f740edae91 | ||
|
|
464c2bdf28 | ||
|
|
8007bac8b8 | ||
|
|
7a1ec266ad | ||
|
|
42fedaa241 | ||
|
|
4387bd4c6f | ||
|
|
51e1367b82 | ||
|
|
8bea3f4422 | ||
|
|
0eb2e405ff | ||
|
|
5dbd4a765f | ||
|
|
f6961c7e06 | ||
|
|
8d3ba90072 | ||
|
|
3772412d82 | ||
|
|
84389f467e | ||
|
|
eb41e0f96f | ||
|
|
2d44dff997 | ||
|
|
1483bf3d46 | ||
|
|
064cf6a3ee | ||
|
|
0c0d5eaa0a | ||
|
|
afecb33b5c | ||
|
|
fbb1e3a302 | ||
|
|
8704350359 | ||
|
|
d09e725d98 | ||
|
|
8be5b149c4 | ||
|
|
faeea6af18 | ||
|
|
3fa5ea1010 | ||
|
|
6aa31ae125 | ||
|
|
27f2e9c286 | ||
|
|
67cc36fdd2 | ||
|
|
ef7297e03b | ||
|
|
97a5314cdb | ||
|
|
a1195a2856 | ||
|
|
81419c6fe3 | ||
|
|
b8da793353 | ||
|
|
8140fa79cc | ||
|
|
abff4fe67d | ||
|
|
ec8f737eba | ||
|
|
cc6688ea00 | ||
|
|
c448b29be7 | ||
|
|
61bde294b3 | ||
|
|
acab81c61e | ||
|
|
1dd965973b | ||
|
|
d61981033f | ||
|
|
30197ba7ce | ||
|
|
1b6c957334 | ||
|
|
fb7a397bf9 | ||
|
|
133c9c5a7b | ||
|
|
8a587d4d21 | ||
|
|
29b813fa3b | ||
|
|
1dfe27d3df | ||
|
|
cda8fc7146 | ||
|
|
acb16f751b | ||
|
|
a1ac276be5 | ||
|
|
54e3ab5139 | ||
|
|
baf341b312 | ||
|
|
5b074c2e22 | ||
|
|
11d086ef12 | ||
|
|
0e6b10e400 | ||
|
|
0240222998 | ||
|
|
7fc739487f | ||
|
|
f6e275709f | ||
|
|
7e01dfd220 | ||
|
|
d5866a99ec | ||
|
|
5289d41b12 | ||
|
|
030178cad2 | ||
|
|
5d00630452 | ||
|
|
eb805bfa2a | ||
|
|
ee3a8e105e | ||
|
|
97fb273e7f | ||
|
|
2ef9009384 | ||
|
|
27c7888628 | ||
|
|
b4de37a9f4 | ||
|
|
1c5ebb54f8 | ||
|
|
f3e69dd6bd | ||
|
|
66364f5ce0 | ||
|
|
f25a1fb865 | ||
|
|
62c5b8b1fc | ||
|
|
2b0de37fc0 | ||
|
|
23ef73fe2f | ||
|
|
92ac3ee4ef | ||
|
|
a3ba5ca109 | ||
|
|
5b4e81cf18 | ||
|
|
772e6f5ebc | ||
|
|
60a9428b8b | ||
|
|
a7752a8421 | ||
|
|
aefa2315b7 | ||
|
|
37a79aeeab | ||
|
|
5bc4bdaeef | ||
|
|
5e28df883d | ||
|
|
0a57748075 | ||
|
|
45e3eee642 | ||
|
|
d724a80c2a | ||
|
|
5ea8c94d18 | ||
|
|
769bc760b3 | ||
|
|
f04f45ea62 | ||
|
|
a5cab6a2a2 | ||
|
|
138611beaf | ||
|
|
e1b608057a | ||
|
|
fed6d8329f | ||
|
|
9d03d52f28 | ||
|
|
055e11174d | ||
|
|
8fda2dd7f1 | ||
|
|
ea03695c75 | ||
|
|
17b206fc72 | ||
|
|
4ec8c5963a | ||
|
|
ab2d8accf5 | ||
|
|
de8b7e9ebe | ||
|
|
18d11523a6 | ||
|
|
7a0ab3c025 | ||
|
|
3575a7dc93 | ||
|
|
bb9e7b1c6e | ||
|
|
115e9e0202 | ||
|
|
e341de70c0 | ||
|
|
1d1a0ac4fd | ||
|
|
d48470ffb1 | ||
|
|
6574ca42a3 | ||
|
|
303ff35a76 | ||
|
|
e0850958b0 | ||
|
|
13115b9ed1 | ||
|
|
933a11e9db | ||
|
|
6915993a35 | ||
|
|
237a4e9a74 | ||
|
|
1565a0fd80 | ||
|
|
e8b16287e0 | ||
|
|
c09e124805 | ||
|
|
b6f55b0e1a | ||
|
|
964bc74b83 | ||
|
|
fa9b142cb7 | ||
|
|
7e3f412c84 | ||
|
|
82e16a5624 | ||
|
|
757488a95b | ||
|
|
d7f154cfd1 | ||
|
|
3517715aab | ||
|
|
d10bbdd7a7 | ||
|
|
c4ec27bb1e | ||
|
|
0b24553ace | ||
|
|
793867269b | ||
|
|
9508e92676 | ||
|
|
89378eae7b | ||
|
|
ace166a925 | ||
|
|
d59d544c0f | ||
|
|
37461d0eb3 | ||
|
|
126152ff63 | ||
|
|
60e19de0d1 | ||
|
|
3247a9facc | ||
|
|
7b114bed26 | ||
|
|
30ffbc760e | ||
|
|
4420913049 | ||
|
|
3762690c5f | ||
|
|
d684ac40d8 | ||
|
|
d217379644 | ||
|
|
d5f7fa2fe5 | ||
|
|
3e0ef10b25 | ||
|
|
28f88f2407 | ||
|
|
e525a7a0ff | ||
|
|
3415f38e0a | ||
|
|
910c0faade | ||
|
|
4ad1bb5e3a | ||
|
|
97f6f0a945 | ||
|
|
bc78c17a11 | ||
|
|
b8e813f7bd | ||
|
|
db3581eb26 | ||
|
|
d23230df68 | ||
|
|
b29781b614 | ||
|
|
7d7c3e7cdb | ||
|
|
cbd8cb80ab | ||
|
|
bfcdc34faf | ||
|
|
c728e6047d | ||
|
|
4c53a9ba8c | ||
|
|
e10a7da7e3 | ||
|
|
5cc431b1bf | ||
|
|
734aa2fcb5 | ||
|
|
5e37319d9b | ||
|
|
2e9eb6e3e9 | ||
|
|
9ce57b123a | ||
|
|
e793168afa | ||
|
|
d1513424e7 | ||
|
|
1436a01dbe | ||
|
|
b9b936b92a | ||
|
|
adf14bec31 | ||
|
|
ca1403ffea | ||
|
|
06672e439e | ||
|
|
e851701a9e | ||
|
|
9589164008 | ||
|
|
a88b067081 | ||
|
|
b3777e6900 | ||
|
|
d2646e291d | ||
|
|
99ab9ee66b | ||
|
|
08678e74e6 | ||
|
|
62de52ab17 | ||
|
|
d9820d9725 | ||
|
|
fe8a8eeac9 | ||
|
|
dfeb414aff | ||
|
|
69f12a2916 | ||
|
|
2b062e938e | ||
|
|
e0299bd1ae | ||
|
|
ac2f1b56fe | ||
|
|
06d98f6fcf | ||
|
|
bb660d15b2 | ||
|
|
4d73cdefef | ||
|
|
313ba3df80 | ||
|
|
15377c32c2 | ||
|
|
22b52f7c4a | ||
|
|
7055f77c91 | ||
|
|
051fe67176 | ||
|
|
90accfcc48 | ||
|
|
4f99db0c90 | ||
|
|
aeb356bf54 | ||
|
|
0dffa0f333 | ||
|
|
d17f5b8447 | ||
|
|
b5a57b3c66 | ||
|
|
987a3404a9 | ||
|
|
eddc30769f | ||
|
|
4d455650ba | ||
|
|
e2157aab26 | ||
|
|
b277f4bf3f | ||
|
|
4047452b0f | ||
|
|
cb37724879 | ||
|
|
8890893412 | ||
|
|
d0cbda7c0d | ||
|
|
60e7b9ffb0 | ||
|
|
45457c6f76 | ||
|
|
737f41d92b | ||
|
|
180841f364 | ||
|
|
bea40d4c2f | ||
|
|
5f9a054441 | ||
|
|
f90bf1ce7c | ||
|
|
8c4ed2d4da | ||
|
|
0e590a1bbf | ||
|
|
218a096135 | ||
|
|
8407bce370 | ||
|
|
43229f0b99 | ||
|
|
84fa0002b9 | ||
|
|
e79c705b20 | ||
|
|
894d7ce15d | ||
|
|
5830880582 | ||
|
|
caab0f70ff | ||
|
|
641966fcdd | ||
|
|
24c22e9bbf | ||
|
|
795f597bda | ||
|
|
2228663a7e | ||
|
|
0c97df357d | ||
|
|
19f63f1be0 | ||
|
|
fc000caf73 | ||
|
|
78929e0293 | ||
|
|
71e22da987 | ||
|
|
24e99d9654 | ||
|
|
98299da424 | ||
|
|
7014af66b6 | ||
|
|
659bd90027 | ||
|
|
146b0c284b | ||
|
|
4a0ac8807f | ||
|
|
d67734832e | ||
|
|
1673bf026a | ||
|
|
1f29b000a9 | ||
|
|
a6d024123e | ||
|
|
fb1a7239ce | ||
|
|
4f71d508cb | ||
|
|
2072bd61d1 | ||
|
|
6021178b7d | ||
|
|
179b0be2bb | ||
|
|
bf2b45dd4a | ||
|
|
513561234c | ||
|
|
33da990ae7 | ||
|
|
4003946e68 | ||
|
|
21f8d40789 | ||
|
|
d6c698e1d6 | ||
|
|
6c227852ae | ||
|
|
29cb22c4fd | ||
|
|
d040bc9e2d | ||
|
|
abb92f23a6 | ||
|
|
da5c86bb69 | ||
|
|
a0d428b12c | ||
|
|
e22fe20e23 | ||
|
|
1e6659aff9 | ||
|
|
60b32d5b05 | ||
|
|
e2ee9053a0 | ||
|
|
d2f0422ecc | ||
|
|
bfd97da626 | ||
|
|
1fd163f0bb | ||
|
|
d15ce575df | ||
|
|
9999ff5a89 | ||
|
|
4653941082 | ||
|
|
fa509661ab | ||
|
|
d9a289bf18 | ||
|
|
98c76b713d | ||
|
|
05ed917a56 | ||
|
|
b833806ec7 | ||
|
|
7fdef3418a | ||
|
|
49e14ec542 | ||
|
|
efd9244684 | ||
|
|
318f2d1f8c | ||
|
|
92fa1cf052 | ||
|
|
17c6eb1680 | ||
|
|
7c6af568d8 | ||
|
|
23c9c6826e | ||
|
|
b08fda5e10 | ||
|
|
5ec3a49377 | ||
|
|
1c728ae432 | ||
|
|
ec021be16c | ||
|
|
8b6826ffa4 | ||
|
|
00cc1ffe74 | ||
|
|
2384fdbaad | ||
|
|
08a93d81d7 | ||
|
|
86911100df | ||
|
|
ff01656268 | ||
|
|
d0ea6d9e8d | ||
|
|
96ca3d5e38 | ||
|
|
3a569499cb | ||
|
|
545b19f978 | ||
|
|
d98be19c9a | ||
|
|
4826898c55 | ||
|
|
482b592f77 | ||
|
|
939ebfe47b | ||
|
|
c6dee1339b | ||
|
|
23f8c3ad3c | ||
|
|
81c1b88376 | ||
|
|
c4a85db698 | ||
|
|
e6eda45c04 | ||
|
|
eb76362de4 | ||
|
|
1cde14859b | ||
|
|
c752b98995 | ||
|
|
1f792ca418 | ||
|
|
b22e08b1eb | ||
|
|
2b5029cc38 | ||
|
|
9e936cb57b | ||
|
|
e8fd2c1b3c | ||
|
|
977fbf54ee | ||
|
|
3e5c91415d | ||
|
|
d60b855f74 | ||
|
|
4146192b6d | ||
|
|
26ee0ff48f | ||
|
|
1763d80d5f | ||
|
|
a594e5147c | ||
|
|
e51ea1a619 | ||
|
|
37c9260dca | ||
|
|
e1a8f4f5db | ||
|
|
b7b0b39afc | ||
|
|
af797489e8 | ||
|
|
b1b756b179 | ||
|
|
9e3372df72 | ||
|
|
657df7a728 | ||
|
|
944f0b694b | ||
|
|
efd409da17 | ||
|
|
08d60c554c | ||
|
|
a428ea7beb | ||
|
|
f69878b082 | ||
|
|
c5ffc2882b | ||
|
|
765691751a | ||
|
|
f19e5977c2 | ||
|
|
8f8b9af862 | ||
|
|
3e7dc71995 | ||
|
|
2a25cd8686 | ||
|
|
7664839135 | ||
|
|
47daebc65a | ||
|
|
0d18b944b6 | ||
|
|
951b5384a3 | ||
|
|
11547ecaa3 | ||
|
|
4c01d7d8f1 | ||
|
|
42ee351487 | ||
|
|
e0383c49cb | ||
|
|
6fbc5b2b14 | ||
|
|
5562559b0b | ||
|
|
c119ffe478 | ||
|
|
27847ab720 | ||
|
|
755b1ed42f | ||
|
|
4e36dc8e5e | ||
|
|
8bc70a4190 | ||
|
|
d798d29e92 | ||
|
|
6e0fee6cb3 | ||
|
|
e0e1f0796b | ||
|
|
e98954c555 | ||
|
|
87fd6afec6 | ||
|
|
dccd6477d2 |
40
.github/instructions/nx.instructions.md
vendored
Normal file
40
.github/instructions/nx.instructions.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
applyTo: '**'
|
||||
---
|
||||
|
||||
// This file is automatically generated by Nx Console
|
||||
|
||||
You are in an nx workspace using Nx 21.3.9 and pnpm as the package manager.
|
||||
|
||||
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
|
||||
|
||||
# General Guidelines
|
||||
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
|
||||
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
|
||||
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
|
||||
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
|
||||
|
||||
# Generation Guidelines
|
||||
If the user wants to generate something, use the following flow:
|
||||
|
||||
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
|
||||
- get the available generators using the 'nx_generators' tool
|
||||
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
|
||||
- get generator details using the 'nx_generator_schema' tool
|
||||
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
|
||||
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
|
||||
- open the generator UI using the 'nx_open_generate_ui' tool
|
||||
- wait for the user to finish the generator
|
||||
- read the generator log file using the 'nx_read_generator_log' tool
|
||||
- use the information provided in the log file to answer the user's question or continue with what they were doing
|
||||
|
||||
# Running Tasks Guidelines
|
||||
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
|
||||
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
|
||||
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
|
||||
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
|
||||
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
|
||||
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.
|
||||
|
||||
|
||||
|
||||
2
.github/workflows/main-docker.yml
vendored
2
.github/workflows/main-docker.yml
vendored
@@ -223,7 +223,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -107,7 +107,7 @@ jobs:
|
||||
docs/Release Notes
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
merge-multiple: true
|
||||
pattern: release-*
|
||||
|
||||
11
.github/workflows/unblock_signing.yml
vendored
Normal file
11
.github/workflows/unblock_signing.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
name: Unblock signing
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unblock-win-signing:
|
||||
runs-on: win-signing
|
||||
steps:
|
||||
- run: |
|
||||
cat ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
|
||||
rm ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
|
||||
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"servers": {
|
||||
"nx-mcp": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:9461/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -35,5 +35,6 @@
|
||||
"docs/**/*.png": true,
|
||||
"apps/server/src/assets/doc_notes/**": true,
|
||||
"apps/edit-docs/demo/**": true
|
||||
}
|
||||
},
|
||||
"nxConsole.generateAiAgentRules": true
|
||||
}
|
||||
161
CLAUDE.md
Normal file
161
CLAUDE.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using NX, with multiple applications and shared packages.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Setup
|
||||
- `pnpm install` - Install all dependencies
|
||||
- `corepack enable` - Enable pnpm if not available
|
||||
|
||||
### Running Applications
|
||||
- `pnpm run server:start` - Start development server (http://localhost:8080)
|
||||
- `pnpm nx run server:serve` - Alternative server start command
|
||||
- `pnpm nx run desktop:serve` - Run desktop Electron app
|
||||
- `pnpm run server:start-prod` - Run server in production mode
|
||||
|
||||
### Building
|
||||
- `pnpm nx build <project>` - Build specific project (server, client, desktop, etc.)
|
||||
- `pnpm run client:build` - Build client application
|
||||
- `pnpm run server:build` - Build server application
|
||||
- `pnpm run electron:build` - Build desktop application
|
||||
|
||||
### Testing
|
||||
- `pnpm test:all` - Run all tests (parallel + sequential)
|
||||
- `pnpm test:parallel` - Run tests that can run in parallel
|
||||
- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math)
|
||||
- `pnpm nx test <project>` - Run tests for specific project
|
||||
- `pnpm coverage` - Generate coverage reports
|
||||
|
||||
### Linting & Type Checking
|
||||
- `pnpm nx run <project>:lint` - Lint specific project
|
||||
- `pnpm nx run <project>:typecheck` - Type check specific project
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Monorepo Structure
|
||||
- **apps/**: Runnable applications
|
||||
- `client/` - Frontend application (shared by server and desktop)
|
||||
- `server/` - Node.js server with web interface
|
||||
- `desktop/` - Electron desktop application
|
||||
- `web-clipper/` - Browser extension for saving web content
|
||||
- Additional tools: `db-compare`, `dump-db`, `edit-docs`
|
||||
|
||||
- **packages/**: Shared libraries
|
||||
- `commons/` - Shared interfaces and utilities
|
||||
- `ckeditor5/` - Custom rich text editor with Trilium-specific plugins
|
||||
- `codemirror/` - Code editor customizations
|
||||
- `highlightjs/` - Syntax highlighting
|
||||
- Custom CKEditor plugins: `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid`
|
||||
|
||||
### Core Architecture Patterns
|
||||
|
||||
#### Three-Layer Cache System
|
||||
- **Becca** (Backend Cache): Server-side entity cache (`apps/server/src/becca/`)
|
||||
- **Froca** (Frontend Cache): Client-side mirror of backend data (`apps/client/src/services/froca.ts`)
|
||||
- **Shaca** (Share Cache): Optimized cache for shared/published notes (`apps/server/src/share/`)
|
||||
|
||||
#### Entity System
|
||||
Core entities are defined in `apps/server/src/becca/entities/`:
|
||||
- `BNote` - Notes with content and metadata
|
||||
- `BBranch` - Hierarchical relationships between notes (allows multiple parents)
|
||||
- `BAttribute` - Key-value metadata attached to notes
|
||||
- `BRevision` - Note version history
|
||||
- `BOption` - Application configuration
|
||||
|
||||
#### Widget-Based UI
|
||||
Frontend uses a widget system (`apps/client/src/widgets/`):
|
||||
- `BasicWidget` - Base class for all UI components
|
||||
- `NoteContextAwareWidget` - Widgets that respond to note changes
|
||||
- `RightPanelWidget` - Widgets displayed in the right panel
|
||||
- Type-specific widgets in `type_widgets/` directory
|
||||
|
||||
#### API Architecture
|
||||
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
|
||||
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
|
||||
- **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`)
|
||||
|
||||
### Key Files for Understanding Architecture
|
||||
|
||||
1. **Application Entry Points**:
|
||||
- `apps/server/src/main.ts` - Server startup
|
||||
- `apps/client/src/desktop.ts` - Client initialization
|
||||
|
||||
2. **Core Services**:
|
||||
- `apps/server/src/becca/becca.ts` - Backend data management
|
||||
- `apps/client/src/services/froca.ts` - Frontend data synchronization
|
||||
- `apps/server/src/services/backend_script_api.ts` - Scripting API
|
||||
|
||||
3. **Database Schema**:
|
||||
- `apps/server/src/assets/db/schema.sql` - Core database structure
|
||||
|
||||
4. **Configuration**:
|
||||
- `nx.json` - NX workspace configuration
|
||||
- `package.json` - Project dependencies and scripts
|
||||
|
||||
## Note Types and Features
|
||||
|
||||
Trilium supports multiple note types, each with specialized widgets:
|
||||
- **Text**: Rich text with CKEditor5 (markdown import/export)
|
||||
- **Code**: Syntax-highlighted code editing with CodeMirror
|
||||
- **File**: Binary file attachments
|
||||
- **Image**: Image display with editing capabilities
|
||||
- **Canvas**: Drawing/diagramming with Excalidraw
|
||||
- **Mermaid**: Diagram generation
|
||||
- **Relation Map**: Visual note relationship mapping
|
||||
- **Web View**: Embedded web pages
|
||||
- **Doc/Book**: Hierarchical documentation structure
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Testing Strategy
|
||||
- Server tests run sequentially due to shared database
|
||||
- Client tests can run in parallel
|
||||
- E2E tests use Playwright for both server and desktop apps
|
||||
- Build validation tests check artifact integrity
|
||||
|
||||
### Scripting System
|
||||
Trilium provides powerful user scripting capabilities:
|
||||
- Frontend scripts run in browser context
|
||||
- Backend scripts run in Node.js context with full API access
|
||||
- Script API documentation available in `docs/Script API/`
|
||||
|
||||
### Internationalization
|
||||
- Translation files in `apps/client/src/translations/`
|
||||
- Supported languages: English, German, Spanish, French, Romanian, Chinese
|
||||
|
||||
### Security Considerations
|
||||
- Per-note encryption with granular protected sessions
|
||||
- CSRF protection for API endpoints
|
||||
- OpenID and TOTP authentication support
|
||||
- Sanitization of user-generated content
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding New Note Types
|
||||
1. Create widget in `apps/client/src/widgets/type_widgets/`
|
||||
2. Register in `apps/client/src/services/note_types.ts`
|
||||
3. Add backend handling in `apps/server/src/services/notes.ts`
|
||||
|
||||
### Extending Search
|
||||
- Search expressions handled in `apps/server/src/services/search/`
|
||||
- Add new search operators in search context files
|
||||
|
||||
### Custom CKEditor Plugins
|
||||
- Create new package in `packages/` following existing plugin structure
|
||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||
|
||||
### Database Migrations
|
||||
- Add migration scripts in `apps/server/src/migrations/`
|
||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||
|
||||
## Build System Notes
|
||||
- Uses NX for monorepo management with build caching
|
||||
- Vite for fast development builds
|
||||
- ESBuild for production optimization
|
||||
- pnpm workspaces for dependency management
|
||||
- Docker support with multi-stage builds
|
||||
17
README.md
17
README.md
@@ -1,10 +1,9 @@
|
||||
# Trilium Notes
|
||||
|
||||
Donate:  
|
||||
|
||||

|
||||

|
||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
|
||||
 
|
||||

|
||||

|
||||
[](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)
|
||||
|
||||
@@ -116,6 +115,14 @@ To install TriliumNext on your own server (including via Docker from [Dockerhub]
|
||||
|
||||
## 💻 Contribute
|
||||
|
||||
### Translations
|
||||
|
||||
If you are a native speaker, help us translate Trilium by heading over to our [Weblate page](https://hosted.weblate.org/engage/trilium/).
|
||||
|
||||
Here's the language coverage we have so far:
|
||||
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
### Code
|
||||
|
||||
Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.54.1",
|
||||
"@stylistic/eslint-plugin": "5.2.0",
|
||||
"@playwright/test": "1.54.2",
|
||||
"@stylistic/eslint-plugin": "5.2.2",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.16.5",
|
||||
"@types/node": "22.17.0",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.31.0",
|
||||
"eslint": "9.32.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"esm": "3.2.25",
|
||||
"jsdoc": "4.0.4",
|
||||
@@ -49,7 +49,7 @@
|
||||
"rcedit": "4.0.1",
|
||||
"rimraf": "6.0.1",
|
||||
"tslib": "2.8.1",
|
||||
"typedoc": "0.28.7",
|
||||
"typedoc": "0.28.9",
|
||||
"typedoc-plugin-missing-exports": "4.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.97.1",
|
||||
"version": "0.97.2",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -10,7 +10,7 @@
|
||||
"url": "https://github.com/TriliumNext/Notes"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "9.31.0",
|
||||
"@eslint/js": "9.32.0",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.18",
|
||||
"@fullcalendar/daygrid": "6.1.18",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@fullcalendar/list": "6.1.18",
|
||||
"@fullcalendar/multimonth": "6.1.18",
|
||||
"@fullcalendar/timegrid": "6.1.18",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.1.8",
|
||||
"@mind-elixir/node-menu": "5.0.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
@@ -38,7 +39,6 @@
|
||||
"i18next": "25.3.2",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery-hotkeys": "0.2.2",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.22",
|
||||
@@ -46,12 +46,12 @@
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "16.1.1",
|
||||
"marked": "16.1.2",
|
||||
"mermaid": "11.9.0",
|
||||
"mind-elixir": "5.0.2",
|
||||
"mind-elixir": "5.0.4",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.26.9",
|
||||
"preact": "10.27.0",
|
||||
"split.js": "1.6.5",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
@@ -59,12 +59,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.32",
|
||||
"@types/leaflet": "1.9.20",
|
||||
"@types/leaflet-gpx": "1.3.7",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/tabulator-tables": "6.2.7",
|
||||
"@types/tabulator-tables": "6.2.9",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"happy-dom": "18.0.1",
|
||||
"script-loader": "0.7.2",
|
||||
|
||||
@@ -133,6 +133,8 @@ export type CommandMappings = {
|
||||
hideLeftPane: CommandData;
|
||||
showCpuArchWarning: CommandData;
|
||||
showLeftPane: CommandData;
|
||||
showAttachments: CommandData;
|
||||
showSearchHistory: CommandData;
|
||||
hoistNote: CommandData & { noteId: string };
|
||||
leaveProtectedSession: CommandData;
|
||||
enterProtectedSession: CommandData;
|
||||
@@ -173,7 +175,7 @@ export type CommandMappings = {
|
||||
deleteNotes: ContextMenuCommandData;
|
||||
importIntoNote: ContextMenuCommandData;
|
||||
exportNote: ContextMenuCommandData;
|
||||
searchInSubtree: ContextMenuCommandData;
|
||||
searchInSubtree: CommandData & { notePath: string; };
|
||||
moveNoteUp: ContextMenuCommandData;
|
||||
moveNoteDown: ContextMenuCommandData;
|
||||
moveNoteUpInHierarchy: ContextMenuCommandData;
|
||||
@@ -262,6 +264,73 @@ export type CommandMappings = {
|
||||
closeThisNoteSplit: CommandData;
|
||||
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
|
||||
jumpToNote: CommandData;
|
||||
commandPalette: CommandData;
|
||||
|
||||
// Keyboard shortcuts
|
||||
backInNoteHistory: CommandData;
|
||||
forwardInNoteHistory: CommandData;
|
||||
forceSaveRevision: CommandData;
|
||||
scrollToActiveNote: CommandData;
|
||||
quickSearch: CommandData;
|
||||
collapseTree: CommandData;
|
||||
createNoteAfter: CommandData;
|
||||
createNoteInto: CommandData;
|
||||
addNoteAboveToSelection: CommandData;
|
||||
addNoteBelowToSelection: CommandData;
|
||||
openNewTab: CommandData;
|
||||
activateNextTab: CommandData;
|
||||
activatePreviousTab: CommandData;
|
||||
openNewWindow: CommandData;
|
||||
toggleTray: CommandData;
|
||||
firstTab: CommandData;
|
||||
secondTab: CommandData;
|
||||
thirdTab: CommandData;
|
||||
fourthTab: CommandData;
|
||||
fifthTab: CommandData;
|
||||
sixthTab: CommandData;
|
||||
seventhTab: CommandData;
|
||||
eigthTab: CommandData;
|
||||
ninthTab: CommandData;
|
||||
lastTab: CommandData;
|
||||
showNoteSource: CommandData;
|
||||
showSQLConsole: CommandData;
|
||||
showBackendLog: CommandData;
|
||||
showCheatsheet: CommandData;
|
||||
showHelp: CommandData;
|
||||
addLinkToText: CommandData;
|
||||
followLinkUnderCursor: CommandData;
|
||||
insertDateTimeToText: CommandData;
|
||||
pasteMarkdownIntoText: CommandData;
|
||||
cutIntoNote: CommandData;
|
||||
addIncludeNoteToText: CommandData;
|
||||
editReadOnlyNote: CommandData;
|
||||
toggleRibbonTabClassicEditor: CommandData;
|
||||
toggleRibbonTabBasicProperties: CommandData;
|
||||
toggleRibbonTabBookProperties: CommandData;
|
||||
toggleRibbonTabFileProperties: CommandData;
|
||||
toggleRibbonTabImageProperties: CommandData;
|
||||
toggleRibbonTabOwnedAttributes: CommandData;
|
||||
toggleRibbonTabInheritedAttributes: CommandData;
|
||||
toggleRibbonTabPromotedAttributes: CommandData;
|
||||
toggleRibbonTabNoteMap: CommandData;
|
||||
toggleRibbonTabNoteInfo: CommandData;
|
||||
toggleRibbonTabNotePaths: CommandData;
|
||||
toggleRibbonTabSimilarNotes: CommandData;
|
||||
toggleRightPane: CommandData;
|
||||
printActiveNote: CommandData;
|
||||
exportAsPdf: CommandData;
|
||||
openNoteExternally: CommandData;
|
||||
renderActiveNote: CommandData;
|
||||
unhoist: CommandData;
|
||||
reloadFrontendApp: CommandData;
|
||||
openDevTools: CommandData;
|
||||
findInText: CommandData;
|
||||
toggleLeftPane: CommandData;
|
||||
toggleFullscreen: CommandData;
|
||||
zoomOut: CommandData;
|
||||
zoomIn: CommandData;
|
||||
zoomReset: CommandData;
|
||||
copyWithoutFormatting: CommandData;
|
||||
|
||||
// Geomap
|
||||
deleteFromMap: { noteId: string };
|
||||
|
||||
@@ -30,13 +30,6 @@ interface CreateChildrenResponse {
|
||||
export default class Entrypoints extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (jQuery.hotkeys) {
|
||||
// hot keys are active also inside inputs and content editables
|
||||
jQuery.hotkeys.options.filterInputAcceptingElements = false;
|
||||
jQuery.hotkeys.options.filterContentEditable = false;
|
||||
jQuery.hotkeys.options.filterTextInputs = false;
|
||||
}
|
||||
}
|
||||
|
||||
openDevToolsCommand() {
|
||||
@@ -113,7 +106,9 @@ export default class Entrypoints extends Component {
|
||||
if (win.isFullScreenable()) {
|
||||
win.setFullScreen(!win.isFullScreen());
|
||||
}
|
||||
} // outside of electron this is handled by the browser
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
reloadFrontendAppCommand() {
|
||||
|
||||
@@ -325,8 +325,9 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some book types must always display a note list, even if no children.
|
||||
if (["calendar", "table", "geoMap"].includes(note.getLabelValue("viewType") ?? "")) {
|
||||
// Collections must always display a note list, even if no children.
|
||||
const viewType = note.getLabelValue("viewType") ?? "grid";
|
||||
if (!["list", "grid"].includes(viewType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "jquery-hotkeys";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
await appContext.earlyInit();
|
||||
|
||||
@@ -23,7 +23,7 @@ let lastTargetNode: HTMLElement | null = null;
|
||||
|
||||
// This will include all commands that implement ContextMenuCommandData, but it will not work if it additional options are added via the `|` operator,
|
||||
// so they need to be added manually.
|
||||
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog";
|
||||
export type TreeCommandNames = FilteredCommandNames<ContextMenuCommandData> | "openBulkActionsDialog" | "searchInSubtree";
|
||||
|
||||
export default class TreeContextMenu implements SelectMenuItemEventListener<TreeCommandNames> {
|
||||
private treeWidget: NoteTreeWidget;
|
||||
@@ -129,7 +129,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
|
||||
},
|
||||
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
|
||||
|
||||
|
||||
{ title: "----" },
|
||||
|
||||
{ title: `${t("tree-context-menu.expand-subtree")} <kbd data-command="expandSubtree"></kbd>`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
|
||||
|
||||
@@ -79,7 +79,19 @@ async function renderAttributes(attributes: FAttribute[], renderIsInheritable: b
|
||||
return $container;
|
||||
}
|
||||
|
||||
const HIDDEN_ATTRIBUTES = ["originalFileName", "fileSize", "template", "inherit", "cssClass", "iconClass", "pageSize", "viewType", "geolocation", "docName"];
|
||||
const HIDDEN_ATTRIBUTES = [
|
||||
"originalFileName",
|
||||
"fileSize",
|
||||
"template",
|
||||
"inherit",
|
||||
"cssClass",
|
||||
"iconClass",
|
||||
"pageSize",
|
||||
"viewType",
|
||||
"geolocation",
|
||||
"docName",
|
||||
"webViewSrc"
|
||||
];
|
||||
|
||||
async function renderNormalAttributes(note: FNote) {
|
||||
const promotedDefinitionAttributes = note.getPromotedDefinitionAttributes();
|
||||
|
||||
@@ -91,10 +91,10 @@ function parseActions(note: FNote) {
|
||||
.filter((action) => !!action);
|
||||
}
|
||||
|
||||
export async function executeBulkActions(parentNoteId: string, actions: BulkAction[]) {
|
||||
export async function executeBulkActions(targetNoteIds: string[], actions: BulkAction[], includeDescendants = false) {
|
||||
await server.post("bulk-action/execute", {
|
||||
noteIds: [ parentNoteId ],
|
||||
includeDescendants: true,
|
||||
noteIds: targetNoteIds,
|
||||
includeDescendants,
|
||||
actions
|
||||
});
|
||||
|
||||
|
||||
295
apps/client/src/services/command_registry.ts
Normal file
295
apps/client/src/services/command_registry.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import { t, translationsInitializedPromise } from "./i18n.js";
|
||||
import keyboardActions from "./keyboard_actions.js";
|
||||
import utils from "./utils.js";
|
||||
|
||||
export interface CommandDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
commandName?: CommandNames;
|
||||
handler?: () => Promise<unknown> | null | undefined | void;
|
||||
aliases?: string[];
|
||||
source?: "manual" | "keyboard-action";
|
||||
/** Reference to the original keyboard action for scope checking. */
|
||||
keyboardAction?: ActionKeyboardShortcut;
|
||||
}
|
||||
|
||||
class CommandRegistry {
|
||||
private commands: Map<string, CommandDefinition> = new Map();
|
||||
private aliases: Map<string, string> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.loadCommands();
|
||||
}
|
||||
|
||||
private async loadCommands() {
|
||||
await translationsInitializedPromise;
|
||||
this.registerDefaultCommands();
|
||||
await this.loadKeyboardActionsAsync();
|
||||
}
|
||||
|
||||
private registerDefaultCommands() {
|
||||
this.register({
|
||||
id: "export-note",
|
||||
name: t("command_palette.export_note_title"),
|
||||
description: t("command_palette.export_note_description"),
|
||||
icon: "bx bx-export",
|
||||
handler: () => {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (notePath) {
|
||||
appContext.triggerCommand("showExportDialog", {
|
||||
notePath,
|
||||
defaultType: "single"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "show-attachments",
|
||||
name: t("command_palette.show_attachments_title"),
|
||||
description: t("command_palette.show_attachments_description"),
|
||||
icon: "bx bx-paperclip",
|
||||
handler: () => appContext.triggerCommand("showAttachments")
|
||||
});
|
||||
|
||||
// Special search commands with custom logic
|
||||
this.register({
|
||||
id: "search-notes",
|
||||
name: t("command_palette.search_notes_title"),
|
||||
description: t("command_palette.search_notes_description"),
|
||||
icon: "bx bx-search",
|
||||
handler: () => appContext.triggerCommand("searchNotes", {})
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "search-in-subtree",
|
||||
name: t("command_palette.search_subtree_title"),
|
||||
description: t("command_palette.search_subtree_description"),
|
||||
icon: "bx bx-search-alt",
|
||||
handler: () => {
|
||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||
if (notePath) {
|
||||
appContext.triggerCommand("searchInSubtree", { notePath });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "show-search-history",
|
||||
name: t("command_palette.search_history_title"),
|
||||
description: t("command_palette.search_history_description"),
|
||||
icon: "bx bx-history",
|
||||
handler: () => appContext.triggerCommand("showSearchHistory")
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: "show-launch-bar",
|
||||
name: t("command_palette.configure_launch_bar_title"),
|
||||
description: t("command_palette.configure_launch_bar_description"),
|
||||
icon: "bx bx-sidebar",
|
||||
handler: () => appContext.triggerCommand("showLaunchBarSubtree")
|
||||
});
|
||||
}
|
||||
|
||||
private async loadKeyboardActionsAsync() {
|
||||
try {
|
||||
const actions = await keyboardActions.getActions();
|
||||
this.registerKeyboardActions(actions);
|
||||
} catch (error) {
|
||||
console.error("Failed to load keyboard actions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private registerKeyboardActions(actions: ActionKeyboardShortcut[]) {
|
||||
for (const action of actions) {
|
||||
// Skip actions that we've already manually registered
|
||||
if (this.commands.has(action.actionName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip actions that don't have a description (likely separators)
|
||||
if (!action.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip Electron-only actions if not in Electron environment
|
||||
if (action.isElectronOnly && !utils.isElectron()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip actions that should not appear in the command palette
|
||||
if (action.ignoreFromCommandPalette) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the primary shortcut (first one in the list)
|
||||
const primaryShortcut = action.effectiveShortcuts?.[0];
|
||||
|
||||
let name = action.friendlyName;
|
||||
if (action.scope === "note-tree") {
|
||||
name = t("command_palette.tree-action-name", { name: action.friendlyName });
|
||||
}
|
||||
|
||||
// Create a command definition from the keyboard action
|
||||
const commandDef: CommandDefinition = {
|
||||
id: action.actionName,
|
||||
name,
|
||||
description: action.description,
|
||||
icon: action.iconClass,
|
||||
shortcut: primaryShortcut ? this.formatShortcut(primaryShortcut) : undefined,
|
||||
commandName: action.actionName as CommandNames,
|
||||
source: "keyboard-action",
|
||||
keyboardAction: action
|
||||
};
|
||||
|
||||
this.register(commandDef);
|
||||
}
|
||||
}
|
||||
|
||||
private formatShortcut(shortcut: string): string {
|
||||
// Convert electron accelerator format to display format
|
||||
return shortcut
|
||||
.replace(/CommandOrControl/g, 'Ctrl')
|
||||
.replace(/\+/g, ' + ');
|
||||
}
|
||||
|
||||
register(command: CommandDefinition) {
|
||||
this.commands.set(command.id, command);
|
||||
|
||||
// Register aliases
|
||||
if (command.aliases) {
|
||||
for (const alias of command.aliases) {
|
||||
this.aliases.set(alias.toLowerCase(), command.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCommand(id: string): CommandDefinition | undefined {
|
||||
return this.commands.get(id);
|
||||
}
|
||||
|
||||
getAllCommands(): CommandDefinition[] {
|
||||
const commands = Array.from(this.commands.values());
|
||||
|
||||
// Sort commands by name
|
||||
commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
searchCommands(query: string): CommandDefinition[] {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const results: { command: CommandDefinition; score: number }[] = [];
|
||||
|
||||
for (const command of this.commands.values()) {
|
||||
let score = 0;
|
||||
|
||||
// Exact match on name
|
||||
if (command.name.toLowerCase() === normalizedQuery) {
|
||||
score = 100;
|
||||
}
|
||||
// Name starts with query
|
||||
else if (command.name.toLowerCase().startsWith(normalizedQuery)) {
|
||||
score = 80;
|
||||
}
|
||||
// Name contains query
|
||||
else if (command.name.toLowerCase().includes(normalizedQuery)) {
|
||||
score = 60;
|
||||
}
|
||||
// Description contains query
|
||||
else if (command.description?.toLowerCase().includes(normalizedQuery)) {
|
||||
score = 40;
|
||||
}
|
||||
// Check aliases
|
||||
else if (command.aliases?.some(alias => alias.toLowerCase().includes(normalizedQuery))) {
|
||||
score = 50;
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
results.push({ command, score });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (highest first) and then by name
|
||||
results.sort((a, b) => {
|
||||
if (a.score !== b.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
return a.command.name.localeCompare(b.command.name);
|
||||
});
|
||||
|
||||
return results.map(r => r.command);
|
||||
}
|
||||
|
||||
async executeCommand(commandId: string) {
|
||||
const command = this.getCommand(commandId);
|
||||
if (!command) {
|
||||
console.error(`Command not found: ${commandId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute custom handler if provided
|
||||
if (command.handler) {
|
||||
await command.handler();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle keyboard action with scope-aware execution
|
||||
if (command.keyboardAction && command.commandName) {
|
||||
if (command.keyboardAction.scope === "note-tree") {
|
||||
this.executeWithNoteTreeFocus(command.commandName);
|
||||
} else if (command.keyboardAction.scope === "text-detail") {
|
||||
this.executeWithTextDetail(command.commandName);
|
||||
} else {
|
||||
appContext.triggerCommand(command.commandName, {
|
||||
ntxId: appContext.tabManager.activeNtxId
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for commands without keyboard action reference
|
||||
if (command.commandName) {
|
||||
appContext.triggerCommand(command.commandName, {
|
||||
ntxId: appContext.tabManager.activeNtxId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Command ${commandId} has no handler or commandName`);
|
||||
}
|
||||
|
||||
private executeWithNoteTreeFocus(actionName: CommandNames) {
|
||||
const tree = document.querySelector(".tree-wrapper") as HTMLElement;
|
||||
if (!tree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const treeComponent = appContext.getComponentByEl(tree) as NoteTreeWidget;
|
||||
const activeNode = treeComponent.getActiveNode();
|
||||
treeComponent.triggerCommand(actionName, {
|
||||
ntxId: appContext.tabManager.activeNtxId,
|
||||
node: activeNode
|
||||
});
|
||||
}
|
||||
|
||||
private async executeWithTextDetail(actionName: CommandNames) {
|
||||
const typeWidget = await appContext.tabManager.getActiveContext()?.getTypeWidget();
|
||||
if (!typeWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
typeWidget.triggerCommand(actionName, {
|
||||
ntxId: appContext.tabManager.activeNtxId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const commandRegistry = new CommandRegistry();
|
||||
export default commandRegistry;
|
||||
@@ -65,6 +65,9 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
|
||||
|
||||
$renderedContent.append($("<div>").append("<div>This note is protected and to access it you need to enter password.</div>").append("<br/>").append($button));
|
||||
} else if (entity instanceof FNote) {
|
||||
$renderedContent
|
||||
.css("display", "flex")
|
||||
.css("flex-direction", "column");
|
||||
$renderedContent.append(
|
||||
$("<div>")
|
||||
.css("display", "flex")
|
||||
@@ -72,8 +75,33 @@ async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FA
|
||||
.css("align-items", "center")
|
||||
.css("height", "100%")
|
||||
.css("font-size", "500%")
|
||||
.css("flex-grow", "1")
|
||||
.append($("<span>").addClass(entity.getIcon()))
|
||||
);
|
||||
|
||||
if (entity.type === "webView" && entity.hasLabel("webViewSrc")) {
|
||||
const $footer = $("<footer>")
|
||||
.addClass("webview-footer");
|
||||
const $openButton = $(`
|
||||
<button class="file-open btn btn-primary" type="button">
|
||||
<span class="bx bx-link-external"></span>
|
||||
${t("content_renderer.open_externally")}
|
||||
</button>
|
||||
`)
|
||||
.appendTo($footer)
|
||||
.on("click", () => {
|
||||
const webViewSrc = entity.getLabelValue("webViewSrc");
|
||||
if (webViewSrc) {
|
||||
if (utils.isElectron()) {
|
||||
const electron = utils.dynamicRequire("electron");
|
||||
electron.shell.openExternal(webViewSrc);
|
||||
} else {
|
||||
window.open(webViewSrc, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
});
|
||||
$footer.appendTo($renderedContent);
|
||||
}
|
||||
}
|
||||
|
||||
if (entity instanceof FNote) {
|
||||
|
||||
@@ -6,6 +6,11 @@ import type { Locale } from "@triliumnext/commons";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
/**
|
||||
* A deferred promise that resolves when translations are initialized.
|
||||
*/
|
||||
export let translationsInitializedPromise = $.Deferred();
|
||||
|
||||
export async function initLocale() {
|
||||
const locale = (options.get("locale") as string) || "en";
|
||||
|
||||
@@ -19,6 +24,8 @@ export async function initLocale() {
|
||||
},
|
||||
returnEmptyString: false
|
||||
});
|
||||
|
||||
translationsInitializedPromise.resolve();
|
||||
}
|
||||
|
||||
export function getAvailableLocales() {
|
||||
|
||||
@@ -2,21 +2,15 @@ import server from "./server.js";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import shortcutService from "./shortcuts.js";
|
||||
import type Component from "../components/component.js";
|
||||
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
|
||||
const keyboardActionRepo: Record<string, Action> = {};
|
||||
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
export interface Action {
|
||||
actionName: CommandNames;
|
||||
effectiveShortcuts: string[];
|
||||
scope: string;
|
||||
}
|
||||
|
||||
const keyboardActionsLoaded = server.get<Action[]>("keyboard-actions").then((actions) => {
|
||||
const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => {
|
||||
actions = actions.filter((a) => !!a.actionName); // filter out separators
|
||||
|
||||
for (const action of actions) {
|
||||
action.effectiveShortcuts = action.effectiveShortcuts.filter((shortcut) => !shortcut.startsWith("global:"));
|
||||
action.effectiveShortcuts = (action.effectiveShortcuts ?? []).filter((shortcut) => !shortcut.startsWith("global:"));
|
||||
|
||||
keyboardActionRepo[action.actionName] = action;
|
||||
}
|
||||
@@ -38,7 +32,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
|
||||
const actions = await getActionsForScope(scope);
|
||||
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
@@ -46,7 +40,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
|
||||
|
||||
getActionsForScope("window").then((actions) => {
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
@@ -80,7 +74,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
|
||||
const action = await getAction(actionName, true);
|
||||
|
||||
if (action) {
|
||||
const keyboardActions = action.effectiveShortcuts.join(", ");
|
||||
const keyboardActions = (action.effectiveShortcuts ?? []).join(", ");
|
||||
|
||||
if (keyboardActions || $(el).text() !== "not set") {
|
||||
$(el).text(keyboardActions);
|
||||
@@ -99,7 +93,7 @@ function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
|
||||
|
||||
if (action) {
|
||||
const title = $(el).attr("title");
|
||||
const shortcuts = action.effectiveShortcuts.join(", ");
|
||||
const shortcuts = (action.effectiveShortcuts ?? []).join(", ");
|
||||
|
||||
if (title?.includes(shortcuts)) {
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import appContext from "../components/app_context.js";
|
||||
import noteCreateService from "./note_create.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import commandRegistry from "./command_registry.js";
|
||||
import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5";
|
||||
|
||||
// this key needs to have this value, so it's hit by the tooltip
|
||||
@@ -29,9 +30,12 @@ export interface Suggestion {
|
||||
notePathTitle?: string;
|
||||
notePath?: string;
|
||||
highlightedNotePathTitle?: string;
|
||||
action?: string | "create-note" | "search-notes" | "external-link";
|
||||
action?: string | "create-note" | "search-notes" | "external-link" | "command";
|
||||
parentNoteId?: string;
|
||||
icon?: string;
|
||||
commandId?: string;
|
||||
commandDescription?: string;
|
||||
commandShortcut?: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
@@ -44,6 +48,8 @@ interface Options {
|
||||
hideGoToSelectedNoteButton?: boolean;
|
||||
/** If set, hides all right-side buttons in the autocomplete dropdown */
|
||||
hideAllButtons?: boolean;
|
||||
/** If set, enables command palette mode */
|
||||
isCommandPalette?: boolean;
|
||||
}
|
||||
|
||||
async function autocompleteSourceForCKEditor(queryText: string) {
|
||||
@@ -73,6 +79,31 @@ async function autocompleteSourceForCKEditor(queryText: string) {
|
||||
}
|
||||
|
||||
async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) {
|
||||
// Check if we're in command mode
|
||||
if (options.isCommandPalette && term.startsWith(">")) {
|
||||
const commandQuery = term.substring(1).trim();
|
||||
|
||||
// Get commands (all if no query, filtered if query provided)
|
||||
const commands = commandQuery.length === 0
|
||||
? commandRegistry.getAllCommands()
|
||||
: commandRegistry.searchCommands(commandQuery);
|
||||
|
||||
// Convert commands to suggestions
|
||||
const commandSuggestions: Suggestion[] = commands.map(cmd => ({
|
||||
action: "command",
|
||||
commandId: cmd.id,
|
||||
noteTitle: cmd.name,
|
||||
notePathTitle: `>${cmd.name}`,
|
||||
highlightedNotePathTitle: cmd.name,
|
||||
commandDescription: cmd.description,
|
||||
commandShortcut: cmd.shortcut,
|
||||
icon: cmd.icon
|
||||
}));
|
||||
|
||||
cb(commandSuggestions);
|
||||
return;
|
||||
}
|
||||
|
||||
const fastSearch = options.fastSearch === false ? false : true;
|
||||
if (fastSearch === false) {
|
||||
if (term.trim().length === 0) {
|
||||
@@ -146,6 +177,12 @@ function showRecentNotes($el: JQuery<HTMLElement>) {
|
||||
$el.trigger("focus");
|
||||
}
|
||||
|
||||
function showAllCommands($el: JQuery<HTMLElement>) {
|
||||
searchDelay = 0;
|
||||
$el.setSelectedNotePath("");
|
||||
$el.autocomplete("val", ">").autocomplete("open");
|
||||
}
|
||||
|
||||
function fullTextSearch($el: JQuery<HTMLElement>, options: Options) {
|
||||
const searchString = $el.autocomplete("val") as unknown as string;
|
||||
if (options.fastSearch === false || searchString?.trim().length === 0) {
|
||||
@@ -270,7 +307,24 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
},
|
||||
displayKey: "notePathTitle",
|
||||
templates: {
|
||||
suggestion: (suggestion) => `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`
|
||||
suggestion: (suggestion) => {
|
||||
if (suggestion.action === "command") {
|
||||
let html = `<div class="command-suggestion">`;
|
||||
html += `<span class="command-icon ${suggestion.icon || "bx bx-terminal"}"></span>`;
|
||||
html += `<div class="command-content">`;
|
||||
html += `<div class="command-name">${suggestion.highlightedNotePathTitle}</div>`;
|
||||
if (suggestion.commandDescription) {
|
||||
html += `<div class="command-description">${suggestion.commandDescription}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
if (suggestion.commandShortcut) {
|
||||
html += `<kbd class="command-shortcut">${suggestion.commandShortcut}</kbd>`;
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
return `<span class="${suggestion.icon ?? "bx bx-note"}"></span> ${suggestion.highlightedNotePathTitle}`;
|
||||
}
|
||||
},
|
||||
// we can't cache identical searches because notes can be created / renamed, new recent notes can be added
|
||||
cache: false
|
||||
@@ -280,6 +334,12 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
|
||||
// TODO: Types fail due to "autocomplete:selected" not being registered in type definitions.
|
||||
($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => {
|
||||
if (suggestion.action === "command") {
|
||||
$el.autocomplete("close");
|
||||
$el.trigger("autocomplete:commandselected", [suggestion]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestion.action === "external-link") {
|
||||
$el.setSelectedNotePath(null);
|
||||
$el.setSelectedExternalLink(suggestion.externalLink);
|
||||
@@ -396,6 +456,7 @@ export default {
|
||||
autocompleteSourceForCKEditor,
|
||||
initNoteAutocomplete,
|
||||
showRecentNotes,
|
||||
showAllCommands,
|
||||
setText,
|
||||
init
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import BoardView from "../widgets/view_widgets/board_view/index.js";
|
||||
import CalendarView from "../widgets/view_widgets/calendar_view.js";
|
||||
import GeoView from "../widgets/view_widgets/geo_view/index.js";
|
||||
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
|
||||
@@ -6,8 +7,9 @@ import TableView from "../widgets/view_widgets/table_view/index.js";
|
||||
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
|
||||
import type ViewMode from "../widgets/view_widgets/view_mode.js";
|
||||
|
||||
const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
|
||||
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
|
||||
export type ViewTypeOptions = "list" | "grid" | "calendar" | "table" | "geoMap";
|
||||
export type ViewTypeOptions = typeof allViewTypes[number];
|
||||
|
||||
export default class NoteListRenderer {
|
||||
|
||||
@@ -23,7 +25,7 @@ export default class NoteListRenderer {
|
||||
#getViewType(parentNote: FNote): ViewTypeOptions {
|
||||
const viewType = parentNote.getLabelValue("viewType");
|
||||
|
||||
if (!["list", "grid", "calendar", "table", "geoMap"].includes(viewType || "")) {
|
||||
if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
|
||||
// when not explicitly set, decide based on the note type
|
||||
return parentNote.type === "search" ? "list" : "grid";
|
||||
} else {
|
||||
@@ -57,6 +59,8 @@ export default class NoteListRenderer {
|
||||
return new TableView(args);
|
||||
case "geoMap":
|
||||
return new GeoView(args);
|
||||
case "board":
|
||||
return new BoardView(args);
|
||||
case "list":
|
||||
case "grid":
|
||||
default:
|
||||
|
||||
@@ -13,8 +13,8 @@ let openTooltipElements: JQuery<HTMLElement>[] = [];
|
||||
let dismissTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function setupGlobalTooltip() {
|
||||
$(document).on("mouseenter", "a", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "[data-href]", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "a:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
$(document).on("mouseenter", "[data-href]:not(.no-tooltip-preview)", mouseEnterHandler);
|
||||
|
||||
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
|
||||
$(document).on("click", (e) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url";
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
@@ -17,7 +17,7 @@ function parse(value: string) {
|
||||
for (const token of tokens) {
|
||||
if (token === "promoted") {
|
||||
defObj.isPromoted = true;
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) {
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
|
||||
defObj.labelType = token as LabelType;
|
||||
} else if (["single", "multi"].includes(token)) {
|
||||
defObj.multiplicity = token as Multiplicity;
|
||||
|
||||
323
apps/client/src/services/shortcuts.spec.ts
Normal file
323
apps/client/src/services/shortcuts.spec.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import shortcuts, { keyMatches, matchesShortcut } from "./shortcuts.js";
|
||||
|
||||
// Mock utils module
|
||||
vi.mock("./utils.js", () => ({
|
||||
default: {
|
||||
isDesktop: () => true
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock jQuery globally since it's used in the shortcuts module
|
||||
const mockElement = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
};
|
||||
|
||||
const mockJQuery = vi.fn(() => [mockElement]);
|
||||
(mockJQuery as any).length = 1;
|
||||
mockJQuery[0] = mockElement;
|
||||
|
||||
(global as any).$ = mockJQuery as any;
|
||||
global.document = mockElement as any;
|
||||
|
||||
describe("shortcuts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any active bindings after each test
|
||||
shortcuts.removeGlobalShortcut("test-namespace");
|
||||
});
|
||||
|
||||
describe("normalizeShortcut", () => {
|
||||
it("should normalize shortcut to lowercase and remove whitespace", () => {
|
||||
expect(shortcuts.normalizeShortcut("Ctrl + A")).toBe("ctrl+a");
|
||||
expect(shortcuts.normalizeShortcut(" SHIFT + F1 ")).toBe("shift+f1");
|
||||
expect(shortcuts.normalizeShortcut("Alt+Space")).toBe("alt+space");
|
||||
});
|
||||
|
||||
it("should handle empty or null shortcuts", () => {
|
||||
expect(shortcuts.normalizeShortcut("")).toBe("");
|
||||
expect(shortcuts.normalizeShortcut(null as any)).toBe(null);
|
||||
expect(shortcuts.normalizeShortcut(undefined as any)).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should handle shortcuts with multiple spaces", () => {
|
||||
expect(shortcuts.normalizeShortcut("Ctrl + Shift + A")).toBe("ctrl+shift+a");
|
||||
});
|
||||
|
||||
it("should warn about malformed shortcuts", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
shortcuts.normalizeShortcut("ctrl+");
|
||||
shortcuts.normalizeShortcut("+a");
|
||||
shortcuts.normalizeShortcut("ctrl++a");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(3);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyMatches", () => {
|
||||
const createKeyboardEvent = (key: string, code?: string) => ({
|
||||
key,
|
||||
code: code || `Key${key.toUpperCase()}`
|
||||
} as KeyboardEvent);
|
||||
|
||||
it("should match regular letter keys using key code", () => {
|
||||
const event = createKeyboardEvent("a", "KeyA");
|
||||
expect(keyMatches(event, "a")).toBe(true);
|
||||
expect(keyMatches(event, "A")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match number keys using digit codes", () => {
|
||||
const event = createKeyboardEvent("1", "Digit1");
|
||||
expect(keyMatches(event, "1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match special keys using key mapping", () => {
|
||||
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "return")).toBe(true);
|
||||
expect(keyMatches({ key: "Enter" } as KeyboardEvent, "enter")).toBe(true);
|
||||
expect(keyMatches({ key: "Delete" } as KeyboardEvent, "del")).toBe(true);
|
||||
expect(keyMatches({ key: "Escape" } as KeyboardEvent, "esc")).toBe(true);
|
||||
expect(keyMatches({ key: " " } as KeyboardEvent, "space")).toBe(true);
|
||||
expect(keyMatches({ key: "ArrowUp" } as KeyboardEvent, "up")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match function keys", () => {
|
||||
expect(keyMatches({ key: "F1" } as KeyboardEvent, "f1")).toBe(true);
|
||||
expect(keyMatches({ key: "F12" } as KeyboardEvent, "f12")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle undefined or null keys", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(keyMatches({} as KeyboardEvent, null as any)).toBe(false);
|
||||
expect(keyMatches({} as KeyboardEvent, undefined as any)).toBe(false);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesShortcut", () => {
|
||||
const createKeyboardEvent = (options: {
|
||||
key: string;
|
||||
code?: string;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
}) => ({
|
||||
key: options.key,
|
||||
code: options.code || `Key${options.key.toUpperCase()}`,
|
||||
ctrlKey: options.ctrlKey || false,
|
||||
altKey: options.altKey || false,
|
||||
shiftKey: options.shiftKey || false,
|
||||
metaKey: options.metaKey || false
|
||||
} as KeyboardEvent);
|
||||
|
||||
it("should match simple key shortcuts", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
expect(matchesShortcut(event, "a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match shortcuts with modifiers", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(event, "ctrl+a")).toBe(true);
|
||||
|
||||
const shiftEvent = createKeyboardEvent({ key: "a", code: "KeyA", shiftKey: true });
|
||||
expect(matchesShortcut(shiftEvent, "shift+a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match complex modifier combinations", () => {
|
||||
const event = createKeyboardEvent({
|
||||
key: "a",
|
||||
code: "KeyA",
|
||||
ctrlKey: true,
|
||||
shiftKey: true
|
||||
});
|
||||
expect(matchesShortcut(event, "ctrl+shift+a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match when modifiers don't match", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(event, "alt+a")).toBe(false);
|
||||
expect(matchesShortcut(event, "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle alternative modifier names", () => {
|
||||
const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
|
||||
expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true);
|
||||
|
||||
const metaEvent = createKeyboardEvent({ key: "a", code: "KeyA", metaKey: true });
|
||||
expect(matchesShortcut(metaEvent, "cmd+a")).toBe(true);
|
||||
expect(matchesShortcut(metaEvent, "command+a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty or invalid shortcuts", () => {
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
expect(matchesShortcut(event, "")).toBe(false);
|
||||
expect(matchesShortcut(event, null as any)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle invalid events", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(matchesShortcut(null as any, "a")).toBe(false);
|
||||
expect(matchesShortcut({} as KeyboardEvent, "a")).toBe(false);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should warn about invalid shortcut formats", () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
|
||||
|
||||
matchesShortcut(event, "ctrl+");
|
||||
matchesShortcut(event, "+");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindGlobalShortcut", () => {
|
||||
it("should bind a global shortcut", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should not bind shortcuts when handler is null", () => {
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", null, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove previous bindings when namespace is reused", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler1, "test-namespace");
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
shortcuts.bindGlobalShortcut("ctrl+b", handler2, "test-namespace");
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindElShortcut", () => {
|
||||
it("should bind shortcut to specific element", () => {
|
||||
const mockEl = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
const mockJQueryEl = [mockEl] as any;
|
||||
mockJQueryEl.length = 1;
|
||||
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should fall back to document when element is empty", () => {
|
||||
const emptyJQuery = [] as any;
|
||||
emptyJQuery.length = 0;
|
||||
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeGlobalShortcut", () => {
|
||||
it("should remove shortcuts for a specific namespace", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
shortcuts.removeGlobalShortcut("test-namespace");
|
||||
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("event handling", () => {
|
||||
it.skip("should call handler when shortcut matches", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
// Get the listener that was registered
|
||||
expect(mockElement.addEventListener.mock.calls).toHaveLength(1);
|
||||
const [, listener] = mockElement.addEventListener.mock.calls[0];
|
||||
|
||||
// First verify that matchesShortcut works directly
|
||||
const testEvent = {
|
||||
type: "keydown",
|
||||
key: "a",
|
||||
code: "KeyA",
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} as any;
|
||||
|
||||
// Test matchesShortcut directly first
|
||||
expect(matchesShortcut(testEvent, "ctrl+a")).toBe(true);
|
||||
|
||||
// Now test the actual listener
|
||||
listener(testEvent);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(testEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(testEvent.stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call handler for non-keyboard events", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
const [, listener] = mockElement.addEventListener.mock.calls[0];
|
||||
|
||||
// Simulate a non-keyboard event
|
||||
const event = {
|
||||
type: "click"
|
||||
} as any;
|
||||
|
||||
listener(event);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call handler when shortcut doesn't match", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
const [, listener] = mockElement.addEventListener.mock.calls[0];
|
||||
|
||||
// Simulate a non-matching keydown event
|
||||
const event = {
|
||||
type: "keydown",
|
||||
key: "b",
|
||||
code: "KeyB",
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn()
|
||||
} as any;
|
||||
|
||||
listener(event);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,18 @@
|
||||
import utils from "./utils.js";
|
||||
|
||||
type ElementType = HTMLElement | Document;
|
||||
type Handler = (e: JQuery.TriggeredEvent<ElementType | Element, string, ElementType | Element, ElementType | Element>) => void;
|
||||
type Handler = (e: KeyboardEvent) => void;
|
||||
|
||||
interface ShortcutBinding {
|
||||
element: HTMLElement | Document;
|
||||
shortcut: string;
|
||||
handler: Handler;
|
||||
namespace: string | null;
|
||||
listener: (evt: Event) => void;
|
||||
}
|
||||
|
||||
// Store all active shortcut bindings for management
|
||||
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
|
||||
|
||||
function removeGlobalShortcut(namespace: string) {
|
||||
bindGlobalShortcut("", null, namespace);
|
||||
@@ -15,38 +26,167 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
|
||||
if (utils.isDesktop()) {
|
||||
keyboardShortcut = normalizeShortcut(keyboardShortcut);
|
||||
|
||||
let eventName = "keydown";
|
||||
|
||||
// If namespace is provided, remove all previous bindings for this namespace
|
||||
if (namespace) {
|
||||
eventName += `.${namespace}`;
|
||||
|
||||
// if there's a namespace, then we replace the existing event handler with the new one
|
||||
$el.off(eventName);
|
||||
removeNamespaceBindings(namespace);
|
||||
}
|
||||
|
||||
// method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
|
||||
if (keyboardShortcut) {
|
||||
$el.bind(eventName, keyboardShortcut, (e) => {
|
||||
if (handler) {
|
||||
handler(e);
|
||||
// Method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
|
||||
if (keyboardShortcut && handler) {
|
||||
const element = $el.length > 0 ? $el[0] as (HTMLElement | Document) : document;
|
||||
|
||||
const listener = (evt: Event) => {
|
||||
// Only handle keyboard events
|
||||
if (evt.type !== 'keydown' || !(evt instanceof KeyboardEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
const e = evt as KeyboardEvent;
|
||||
if (matchesShortcut(e, keyboardShortcut)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handler(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener
|
||||
element.addEventListener('keydown', listener);
|
||||
|
||||
// Store the binding for later cleanup
|
||||
const binding: ShortcutBinding = {
|
||||
element,
|
||||
shortcut: keyboardShortcut,
|
||||
handler,
|
||||
namespace,
|
||||
listener
|
||||
};
|
||||
|
||||
const key = namespace || 'global';
|
||||
if (!activeBindings.has(key)) {
|
||||
activeBindings.set(key, []);
|
||||
}
|
||||
activeBindings.get(key)!.push(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeNamespaceBindings(namespace: string) {
|
||||
const bindings = activeBindings.get(namespace);
|
||||
if (bindings) {
|
||||
// Remove all event listeners for this namespace
|
||||
bindings.forEach(binding => {
|
||||
binding.element.removeEventListener('keydown', binding.listener);
|
||||
});
|
||||
activeBindings.delete(namespace);
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
|
||||
if (!shortcut) return false;
|
||||
|
||||
// Ensure we have a proper KeyboardEvent with key property
|
||||
if (!e || typeof e.key !== 'string') {
|
||||
console.warn('matchesShortcut called with invalid event:', e);
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = shortcut.toLowerCase().split('+');
|
||||
const key = parts[parts.length - 1]; // Last part is the actual key
|
||||
const modifiers = parts.slice(0, -1); // Everything before is modifiers
|
||||
|
||||
// Defensive check - ensure we have a valid key
|
||||
if (!key || key.trim() === '') {
|
||||
console.warn('Invalid shortcut format:', shortcut);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the main key matches
|
||||
if (!keyMatches(e, key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifiers
|
||||
const expectedCtrl = modifiers.includes('ctrl') || modifiers.includes('control');
|
||||
const expectedAlt = modifiers.includes('alt');
|
||||
const expectedShift = modifiers.includes('shift');
|
||||
const expectedMeta = modifiers.includes('meta') || modifiers.includes('cmd') || modifiers.includes('command');
|
||||
|
||||
return e.ctrlKey === expectedCtrl &&
|
||||
e.altKey === expectedAlt &&
|
||||
e.shiftKey === expectedShift &&
|
||||
e.metaKey === expectedMeta;
|
||||
}
|
||||
|
||||
export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
// Defensive check for undefined/null key
|
||||
if (!key) {
|
||||
console.warn('keyMatches called with undefined/null key');
|
||||
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);
|
||||
}
|
||||
|
||||
// For number keys, use the physical key code regardless of modifiers
|
||||
// This works across all keyboard layouts
|
||||
if (key >= '0' && key <= '9') {
|
||||
return e.code === `Digit${key}`;
|
||||
}
|
||||
|
||||
// For letter keys, use the physical key code for consistency
|
||||
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
||||
return e.code === `Key${key.toUpperCase()}`;
|
||||
}
|
||||
|
||||
// For regular keys, check both key and code as fallback
|
||||
return e.key.toLowerCase() === key.toLowerCase() ||
|
||||
e.code.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize to the form expected by the jquery.hotkeys.js
|
||||
* Simple normalization - just lowercase and trim whitespace
|
||||
*/
|
||||
function normalizeShortcut(shortcut: string): string {
|
||||
if (!shortcut) {
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
return shortcut.toLowerCase().replace("enter", "return").replace("delete", "del").replace("ctrl+alt", "alt+ctrl").replace("meta+alt", "alt+meta"); // alt needs to be first;
|
||||
const normalized = shortcut.toLowerCase().trim().replace(/\s+/g, '');
|
||||
|
||||
// Warn about potentially problematic shortcuts
|
||||
if (normalized.endsWith('+') || normalized.startsWith('+') || normalized.includes('++')) {
|
||||
console.warn('Potentially malformed shortcut:', shortcut, '-> normalized to:', normalized);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -51,6 +51,14 @@ export default class SpacedUpdate {
|
||||
this.lastUpdated = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the update interval for the spaced update.
|
||||
* @param interval The update interval in milliseconds.
|
||||
*/
|
||||
setUpdateInterval(interval: number) {
|
||||
this.updateInterval = interval;
|
||||
}
|
||||
|
||||
triggerUpdate() {
|
||||
if (!this.changed) {
|
||||
return;
|
||||
|
||||
@@ -36,7 +36,9 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
|
||||
const $copyButton = $("<button>")
|
||||
.addClass("bx component icon-action tn-tool-button bx-copy copy-button")
|
||||
.attr("title", t("code_block.copy_title"))
|
||||
.on("click", () => {
|
||||
.on("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isShare) {
|
||||
copyTextWithToast($codeBlock.text());
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "jquery";
|
||||
import "jquery-hotkeys";
|
||||
import utils from "./services/utils.js";
|
||||
import ko from "knockout";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
|
||||
@@ -29,6 +29,14 @@ async function formatCodeBlocks() {
|
||||
await formatCodeBlocks($("#content"));
|
||||
}
|
||||
|
||||
async function setupTextNote() {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
|
||||
const setupMermaid = (await import("./share/mermaid.js")).default;
|
||||
setupMermaid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch note with given ID from backend
|
||||
*
|
||||
@@ -47,8 +55,11 @@ async function fetchNote(noteId: string | null = null) {
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
const noteType = determineNoteType();
|
||||
|
||||
if (noteType === "text") {
|
||||
setupTextNote();
|
||||
}
|
||||
|
||||
const toggleMenuButton = document.getElementById("toggleMenuButton");
|
||||
const layout = document.getElementById("layout");
|
||||
@@ -60,6 +71,12 @@ document.addEventListener(
|
||||
false
|
||||
);
|
||||
|
||||
function determineNoteType() {
|
||||
const bodyClass = document.body.className;
|
||||
const match = bodyClass.match(/type-([^\s]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// workaround to prevent webpack from removing "fetchNote" as dead code:
|
||||
// add fetchNote as property to the window object
|
||||
Object.defineProperty(window, "fetchNote", {
|
||||
|
||||
17
apps/client/src/share/mermaid.ts
Normal file
17
apps/client/src/share/mermaid.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mermaid from "mermaid";
|
||||
|
||||
export default function setupMermaid() {
|
||||
for (const codeBlock of document.querySelectorAll("#content pre code.language-mermaid")) {
|
||||
const parentPre = codeBlock.parentElement;
|
||||
if (!parentPre) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mermaidDiv = document.createElement("div");
|
||||
mermaidDiv.classList.add("mermaid");
|
||||
mermaidDiv.innerHTML = codeBlock.innerHTML;
|
||||
parentPre.replaceWith(mermaidDiv);
|
||||
}
|
||||
|
||||
mermaid.init();
|
||||
}
|
||||
@@ -320,3 +320,8 @@ h6 {
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
figure.table {
|
||||
/* Workaround for https://github.com/ckeditor/ckeditor5/issues/18903. Remove once official fix is released */
|
||||
display: table !important;
|
||||
}
|
||||
@@ -139,12 +139,6 @@ textarea,
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/* Restore default apperance */
|
||||
input[type="number"],
|
||||
input[type="checkbox"] {
|
||||
appearance: auto !important;
|
||||
}
|
||||
|
||||
/* Add a gap between consecutive radios / check boxes */
|
||||
label.tn-radio + label.tn-radio,
|
||||
label.tn-checkbox + label.tn-checkbox {
|
||||
@@ -1786,6 +1780,54 @@ textarea {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Command palette styling */
|
||||
.jump-to-note-dialog .command-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .aa-suggestion .command-suggestion,
|
||||
.jump-to-note-dialog .aa-suggestion .command-suggestion div {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .aa-cursor .command-suggestion,
|
||||
.jump-to-note-dialog .aa-suggestion:hover .command-suggestion {
|
||||
border-left-color: var(--link-color);
|
||||
background-color: var(--hover-background-color);
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-icon {
|
||||
color: var(--muted-text-color);
|
||||
font-size: 1.125rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .command-description {
|
||||
font-size: 0.8em;
|
||||
line-height: 1.3;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog kbd.command-shortcut {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.empty-table-placeholder {
|
||||
text-align: center;
|
||||
color: var(--muted-text-color);
|
||||
@@ -1895,12 +1937,14 @@ body.zen .note-title-widget input {
|
||||
|
||||
/* Content renderer */
|
||||
|
||||
footer.file-footer {
|
||||
footer.file-footer,
|
||||
footer.webview-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
footer.file-footer button {
|
||||
footer.file-footer button,
|
||||
footer.webview-footer button {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -458,6 +458,11 @@ body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-image .rendered-content,
|
||||
.note-list-wrapper .note-book-card .note-book-content.type-pdf .rendered-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .note-book-content .rendered-content.text-with-ellipsis {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,15 @@ div.tn-tool-dialog {
|
||||
|
||||
.jump-to-note-dialog .modal-header {
|
||||
padding: unset !important;
|
||||
padding-bottom: 26px !important;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-body {
|
||||
padding: 26px 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.jump-to-note-dialog .modal-footer {
|
||||
padding-top: 26px;
|
||||
}
|
||||
|
||||
/* Search box wrapper */
|
||||
|
||||
@@ -1678,4 +1678,42 @@ div.find-replace-widget div.find-widget-found-wrapper > span {
|
||||
#right-pane .highlights-list li:active {
|
||||
background: transparent;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/** Canvas **/
|
||||
|
||||
.excalidraw {
|
||||
--border-radius-lg: 6px;
|
||||
}
|
||||
|
||||
.excalidraw .Island {
|
||||
backdrop-filter: var(--dropdown-backdrop-filter);
|
||||
}
|
||||
|
||||
.excalidraw .Island.App-toolbar {
|
||||
--island-bg-color: var(--floating-button-background-color);
|
||||
--shadow-island: 1px 1px 1px var(--floating-button-shadow-color);
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu {
|
||||
border: unset !important;
|
||||
box-shadow: unset !important;
|
||||
background-color: transparent !important;
|
||||
--island-bg-color: var(--menu-background-color);
|
||||
--shadow-island: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));
|
||||
--default-border-color: var(--bs-dropdown-divider-bg);
|
||||
--button-hover-bg: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu .dropdown-menu-container {
|
||||
border-radius: var(--dropdown-border-radius);
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu .dropdown-menu-container > div:not([class]):not(:last-child) {
|
||||
margin-left: calc(var(--padding) * var(--space-factor) * -1) !important;
|
||||
margin-right: calc(var(--padding) * var(--space-factor) * -1) !important;
|
||||
}
|
||||
|
||||
.excalidraw .dropdown-menu:before {
|
||||
content: unset !important;
|
||||
}
|
||||
214
apps/client/src/translations/ca/translation.json
Normal file
214
apps/client/src/translations/ca/translation.json
Normal file
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Sobre Trilium Notes",
|
||||
"close": "Tanca",
|
||||
"homepage": "Pàgina principal:"
|
||||
},
|
||||
"add_link": {
|
||||
"note": "Nota",
|
||||
"close": "Tanca"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"close": "Tanca",
|
||||
"prefix": "Prefix: ",
|
||||
"save": "Desa"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"close": "Tanca",
|
||||
"labels": "Etiquetes",
|
||||
"relations": "Relacions",
|
||||
"notes": "Notes",
|
||||
"other": "Altres"
|
||||
},
|
||||
"clone_to": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Confirmació",
|
||||
"close": "Tanca",
|
||||
"cancel": "Cancel·la",
|
||||
"ok": "OK"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Tanca",
|
||||
"cancel": "Cancel·la",
|
||||
"ok": "OK"
|
||||
},
|
||||
"export": {
|
||||
"close": "Tanca",
|
||||
"export": "Exporta"
|
||||
},
|
||||
"help": {
|
||||
"close": "Tanca",
|
||||
"troubleshooting": "Solució de problemes",
|
||||
"other": "Altres"
|
||||
},
|
||||
"import": {
|
||||
"close": "Tanca",
|
||||
"options": "Opcions",
|
||||
"import": "Importa"
|
||||
},
|
||||
"include_note": {
|
||||
"close": "Tanca",
|
||||
"label_note": "Nota"
|
||||
},
|
||||
"info": {
|
||||
"closeButton": "Tanca",
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"markdown_import": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"move_to": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"close": "Tanca",
|
||||
"templates": "Plantilles:"
|
||||
},
|
||||
"password_not_set": {
|
||||
"close": "Tanca"
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Sol·licitud",
|
||||
"close": "Tanca",
|
||||
"defaultTitle": "Sol·licitud"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Tanca"
|
||||
},
|
||||
"recent_changes": {
|
||||
"close": "Tanca",
|
||||
"undelete_link": "recuperar"
|
||||
},
|
||||
"revisions": {
|
||||
"close": "Tanca",
|
||||
"restore_button": "Restaura",
|
||||
"delete_button": "Suprimeix",
|
||||
"download_button": "Descarrega",
|
||||
"mime": "MIME: ",
|
||||
"preview": "Vista prèvia:"
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"close": "Tanca",
|
||||
"title": "títol",
|
||||
"ascending": "ascendent",
|
||||
"descending": "descendent",
|
||||
"folders": "Carpetes"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"close": "Tanca",
|
||||
"options": "Opcions",
|
||||
"upload": "Puja"
|
||||
},
|
||||
"attribute_detail": {
|
||||
"name": "Nom",
|
||||
"value": "Valor",
|
||||
"promoted": "Destacat",
|
||||
"promoted_alias": "Àlies",
|
||||
"multiplicity": "Multiplicitat",
|
||||
"label_type": "Tipus",
|
||||
"text": "Text",
|
||||
"number": "Número",
|
||||
"boolean": "Booleà",
|
||||
"date": "Data",
|
||||
"time": "Hora",
|
||||
"url": "URL",
|
||||
"precision": "Precisió",
|
||||
"digits": "dígits",
|
||||
"inheritable": "Heretable",
|
||||
"delete": "Suprimeix",
|
||||
"color_type": "Color"
|
||||
},
|
||||
"rename_label": {
|
||||
"to": "Per"
|
||||
},
|
||||
"move_note": {
|
||||
"to": "a"
|
||||
},
|
||||
"add_relation": {
|
||||
"to": "a"
|
||||
},
|
||||
"rename_relation": {
|
||||
"to": "Per"
|
||||
},
|
||||
"update_relation_target": {
|
||||
"to": "a"
|
||||
},
|
||||
"attachments_actions": {
|
||||
"download": "Descarrega"
|
||||
},
|
||||
"calendar": {
|
||||
"mon": "Dl",
|
||||
"tue": "Dt",
|
||||
"wed": "dc",
|
||||
"thu": "Dj",
|
||||
"fri": "Dv",
|
||||
"sat": "Ds",
|
||||
"sun": "Dg",
|
||||
"january": "Gener",
|
||||
"febuary": "Febrer",
|
||||
"march": "Març",
|
||||
"april": "Abril",
|
||||
"may": "Maig",
|
||||
"june": "Juny",
|
||||
"july": "Juliol",
|
||||
"august": "Agost",
|
||||
"september": "Setembre",
|
||||
"october": "Octubre",
|
||||
"november": "Novembre",
|
||||
"december": "Desembre"
|
||||
},
|
||||
"global_menu": {
|
||||
"menu": "Menú",
|
||||
"options": "Opcions",
|
||||
"zoom": "Zoom",
|
||||
"advanced": "Avançat",
|
||||
"logout": "Tanca la sessió"
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"relation": "relació"
|
||||
},
|
||||
"note_icon": {
|
||||
"category": "Categoria:",
|
||||
"search": "Cerca:"
|
||||
},
|
||||
"basic_properties": {
|
||||
"editable": "Editable",
|
||||
"language": "Llengua"
|
||||
},
|
||||
"book_properties": {
|
||||
"grid": "Graella",
|
||||
"list": "Llista",
|
||||
"collapse": "Replega",
|
||||
"expand": "Desplega",
|
||||
"calendar": "Calendari",
|
||||
"table": "Taula",
|
||||
"board": "Tauler"
|
||||
},
|
||||
"edited_notes": {
|
||||
"deleted": "(suprimit)"
|
||||
},
|
||||
"file_properties": {
|
||||
"download": "Descarrega",
|
||||
"open": "Obre",
|
||||
"title": "Fitxer"
|
||||
},
|
||||
"image_properties": {
|
||||
"download": "Descarrega",
|
||||
"open": "Obre",
|
||||
"title": "Imatge"
|
||||
},
|
||||
"note_info_widget": {
|
||||
"created": "Creat",
|
||||
"modified": "Modificat",
|
||||
"type": "Tipus",
|
||||
"calculate": "calcula"
|
||||
},
|
||||
"note_paths": {
|
||||
"archived": "Arxivat"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
19
apps/client/src/translations/el/translation.json
Normal file
19
apps/client/src/translations/el/translation.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Πληροφορίες για το Trilium Notes",
|
||||
"close": "Κλείσιμο",
|
||||
"homepage": "Αρχική Σελίδα:",
|
||||
"app_version": "Έκδοση εφαρμογής:",
|
||||
"db_version": "Έκδοση βάσης δεδομένων:",
|
||||
"sync_version": "Έκδοση πρωτοκόλου συγχρονισμού:",
|
||||
"build_date": "Ημερομηνία χτισίματος εφαρμογής:",
|
||||
"build_revision": "Αριθμός αναθεώρησης χτισίματος:",
|
||||
"data_directory": "Φάκελος δεδομένων:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Κρίσιμο σφάλμα",
|
||||
"message": "Συνέβη κάποιο κρίσιμο σφάλμα, το οποίο δεν επιτρέπει στην εφαρμογή χρήστη να ξεκινήσει:\n\n{{message}}\n\nΤο πιθανότερο είναι να προκλήθηκε από κάποιο script που απέτυχε απρόοπτα. Δοκιμάστε να ξεκινήσετε την εφαρμογή σε ασφαλή λειτουργία για να λύσετε το πρόβλημα."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,7 @@
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "search for note by its name",
|
||||
"search_placeholder": "Search for note by its name or type > for commands...",
|
||||
"close": "Close",
|
||||
"search_button": "Search in full text <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
@@ -443,7 +443,8 @@
|
||||
"other_notes_with_name": "Other notes with {{attributeType}} name \"{{attributeName}}\"",
|
||||
"and_more": "... and {{count}} more.",
|
||||
"print_landscape": "When exporting to PDF, changes the orientation of the page to landscape instead of portrait.",
|
||||
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <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>."
|
||||
"print_page_size": "When exporting to PDF, changes the size of the page. Supported values: <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": "Color"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "To add label, just type e.g. <code>#rock</code> or if you want to add also value then e.g. <code>#year = 2020</code>",
|
||||
@@ -762,7 +763,8 @@
|
||||
"invalid_view_type": "Invalid view type '{{type}}'",
|
||||
"calendar": "Calendar",
|
||||
"table": "Table",
|
||||
"geo-map": "Geo Map"
|
||||
"geo-map": "Geo Map",
|
||||
"board": "Board"
|
||||
},
|
||||
"edited_notes": {
|
||||
"no_edited_notes_found": "No edited notes on this day yet...",
|
||||
@@ -839,7 +841,8 @@
|
||||
"unknown_label_type": "Unknown label type '{{type}}'",
|
||||
"unknown_attribute_type": "Unknown attribute type '{{type}}'",
|
||||
"add_new_attribute": "Add new attribute",
|
||||
"remove_this_attribute": "Remove this attribute"
|
||||
"remove_this_attribute": "Remove this attribute",
|
||||
"remove_color": "Remove the color label"
|
||||
},
|
||||
"script_executor": {
|
||||
"query": "Query",
|
||||
@@ -1615,7 +1618,7 @@
|
||||
"relation-map": "Relation Map",
|
||||
"note-map": "Note Map",
|
||||
"render-note": "Render Note",
|
||||
"book": "Book",
|
||||
"book": "Collection",
|
||||
"mermaid-diagram": "Mermaid Diagram",
|
||||
"canvas": "Canvas",
|
||||
"web-view": "Web View",
|
||||
@@ -1964,9 +1967,46 @@
|
||||
},
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "Hide weekends",
|
||||
"display-week-numbers": "Display week numbers"
|
||||
"display-week-numbers": "Display week numbers",
|
||||
"map-style": "Map style:",
|
||||
"max-nesting-depth": "Max nesting depth:",
|
||||
"raster": "Raster",
|
||||
"vector_light": "Vector (Light)",
|
||||
"vector_dark": "Vector (Dark)",
|
||||
"show-scale": "Show scale"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Delete row"
|
||||
},
|
||||
"board_view": {
|
||||
"delete-note": "Delete Note",
|
||||
"move-to": "Move to",
|
||||
"insert-above": "Insert above",
|
||||
"insert-below": "Insert below",
|
||||
"delete-column": "Delete column",
|
||||
"delete-column-confirmation": "Are you sure you want to delete this column? The corresponding attribute will be deleted in the notes under this column as well.",
|
||||
"new-item": "New item",
|
||||
"add-column": "Add Column"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "Tree: {{name}}",
|
||||
"export_note_title": "Export Note",
|
||||
"export_note_description": "Export current note",
|
||||
"show_attachments_title": "Show Attachments",
|
||||
"show_attachments_description": "View note attachments",
|
||||
"search_notes_title": "Search Notes",
|
||||
"search_notes_description": "Open advanced search",
|
||||
"search_subtree_title": "Search in Subtree",
|
||||
"search_subtree_description": "Search within current subtree",
|
||||
"search_history_title": "Show Search History",
|
||||
"search_history_description": "View previous searches",
|
||||
"configure_launch_bar_title": "Configure Launch Bar",
|
||||
"configure_launch_bar_description": "Open the launch bar configuration, to add or remove items."
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "Open externally"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Close"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
379
apps/client/src/translations/it/translation.json
Normal file
379
apps/client/src/translations/it/translation.json
Normal file
@@ -0,0 +1,379 @@
|
||||
{
|
||||
"about": {
|
||||
"close": "Chiudi",
|
||||
"app_version": "Versione dell'app:",
|
||||
"db_version": "Versione DB:",
|
||||
"sync_version": "Versione Sync:",
|
||||
"data_directory": "Cartella dati:",
|
||||
"title": "Informazioni su Trilium Notes",
|
||||
"build_date": "Data della build:",
|
||||
"build_revision": "Revisione della build:",
|
||||
"homepage": "Homepage:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Errore critico",
|
||||
"message": "Si è verificato un errore critico che impedisce l'avvio dell'applicazione client:\n\n{{message}}\n\nQuesto è probabilmente causato da un errore di script inaspettato. Prova a avviare l'applicazione in modo sicuro e controlla il problema."
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Non si è riusciti a caricare uno script personalizzato",
|
||||
"message": "Lo script della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Impossibile inizializzare un widget",
|
||||
"message-custom": "Il widget personalizzato della nota con ID \"{{id}}\", dal titolo \"{{title}}\" non è stato inizializzato a causa di:\n\n{{message}}",
|
||||
"message-unknown": "Un widget sconosciuto non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Aggiungi un collegamento",
|
||||
"close": "Chiudi",
|
||||
"note": "Nota",
|
||||
"search_note": "cerca una nota per nome",
|
||||
"link_title_mirrors": "il titolo del collegamento rispecchia il titolo della nota corrente",
|
||||
"link_title_arbitrary": "il titolo del collegamento può essere modificato arbitrariamente",
|
||||
"link_title": "Titolo del collegamento",
|
||||
"button_add_link": "Aggiungi il collegamento <kbd>invio</kbd>",
|
||||
"help_on_links": "Aiuto sui collegamenti"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Modifica il prefisso del ramo",
|
||||
"help_on_tree_prefix": "Aiuto sui prefissi dell'Albero",
|
||||
"close": "Chiudi",
|
||||
"prefix": "Prefisso: ",
|
||||
"save": "Salva",
|
||||
"branch_prefix_saved": "Il prefisso del ramo è stato salvato."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Azioni massive",
|
||||
"close": "Chiudi",
|
||||
"affected_notes": "Note influenzate",
|
||||
"include_descendants": "Includi i discendenti della nota selezionata",
|
||||
"available_actions": "Azioni disponibili",
|
||||
"chosen_actions": "Azioni scelte",
|
||||
"execute_bulk_actions": "Esegui le azioni massive",
|
||||
"bulk_actions_executed": "Le azioni massive sono state eseguite con successo.",
|
||||
"none_yet": "Ancora nessuna... aggiungi una azione cliccando su una di quelle disponibili sopra.",
|
||||
"labels": "Etichette",
|
||||
"relations": "Relazioni",
|
||||
"notes": "Note",
|
||||
"other": "Altro"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clona note in...",
|
||||
"close": "Chiudi",
|
||||
"help_on_links": "Aiuto sui collegamenti",
|
||||
"notes_to_clone": "Note da clonare",
|
||||
"target_parent_note": "Nodo padre obiettivo",
|
||||
"search_for_note_by_its_name": "cerca una nota per nome",
|
||||
"cloned_note_prefix_title": "Le note clonate saranno mostrate nell'albero delle note con il dato prefisso",
|
||||
"prefix_optional": "Prefisso (opzionale)",
|
||||
"clone_to_selected_note": "Clona sotto la nota selezionata <kbd>invio</kbd>",
|
||||
"no_path_to_clone_to": "Nessun percorso per clonare dentro.",
|
||||
"note_cloned": "La nota \"{{clonedTitle}}\" è stata clonata in \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"close": "Chiudi",
|
||||
"cancel": "Annulla",
|
||||
"ok": "OK",
|
||||
"confirmation": "Conferma",
|
||||
"are_you_sure_remove_note": "Sei sicuro di voler rimuovere la nota \"{{title}}\" dalla mappa delle relazioni? ",
|
||||
"if_you_dont_check": "Se non lo selezioni, la nota sarà rimossa solamente dalla mappa delle relazioni.",
|
||||
"also_delete_note": "Rimuove anche la nota"
|
||||
},
|
||||
"delete_notes": {
|
||||
"ok": "OK",
|
||||
"close": "Chiudi",
|
||||
"delete_notes_preview": "Anteprima di eliminazione delle note",
|
||||
"delete_all_clones_description": "Elimina anche tutti i cloni (può essere disfatto tramite i cambiamenti recenti)",
|
||||
"erase_notes_description": "L'eliminazione normale (soft) marca le note come eliminate e potranno essere recuperate entro un certo lasso di tempo (dalla finestra dei cambiamenti recenti). Selezionando questa opzione le note si elimineranno immediatamente e non sarà possibile recuperarle.",
|
||||
"erase_notes_warning": "Elimina le note in modo permanente (non potrà essere disfatto), compresi tutti i cloni. Ciò forzerà un nuovo caricamento dell'applicazione.",
|
||||
"cancel": "Annulla",
|
||||
"notes_to_be_deleted": "Le seguenti note saranno eliminate ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nessuna nota sarà eliminata (solo i cloni).",
|
||||
"broken_relations_to_be_deleted": "Le seguenti relazioni saranno interrotte ed eliminate ({{- relationCount}})",
|
||||
"deleted_relation_text": "La nota {{- note}} (da eliminare) è referenziata dalla relazione {{- relation}} originata da {{- source}}."
|
||||
},
|
||||
"info": {
|
||||
"okButton": "OK",
|
||||
"closeButton": "Chiudi"
|
||||
},
|
||||
"export": {
|
||||
"close": "Chiudi",
|
||||
"export_note_title": "Esporta la nota",
|
||||
"export_status": "Stato dell'esportazione",
|
||||
"export": "Esporta",
|
||||
"choose_export_type": "Scegli prima il tipo di esportazione, per favore",
|
||||
"export_in_progress": "Esportazione in corso: {{progressCount}}",
|
||||
"export_finished_successfully": "Esportazione terminata con successo.",
|
||||
"format_pdf": "PDF- allo scopo di stampa o esportazione."
|
||||
},
|
||||
"help": {
|
||||
"close": "Chiudi",
|
||||
"fullDocumentation": "Aiuto (la documentazione completa è disponibile <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)"
|
||||
},
|
||||
"import": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"include_note": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"markdown_import": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"move_to": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"password_not_set": {
|
||||
"close": "Chiudi",
|
||||
"body1": "Le note protette sono crittografate utilizzando una password utente, ma la password non è stata ancora impostata.",
|
||||
"body2": "Per proteggere le note, fare clic su <a class=\"open-password-options-button\" href=\"javascript:\">qui</a> per aprire la finestra di dialogo Opzioni e impostare la password."
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Chiudi"
|
||||
},
|
||||
"prompt": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"recent_changes": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"revisions": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"abstract_bulk_action": {
|
||||
"remove_this_search_action": "Rimuovi questa azione di ricerca"
|
||||
},
|
||||
"etapi": {
|
||||
"new_token_title": "Nuovo token ETAPI",
|
||||
"new_token_message": "Inserire il nuovo nome del token"
|
||||
},
|
||||
"electron_integration": {
|
||||
"zoom-factor": "Fattore di ingrandimento",
|
||||
"desktop-application": "Applicazione Desktop"
|
||||
},
|
||||
"note_autocomplete": {
|
||||
"search-for": "Cerca \"{{term}}\"",
|
||||
"create-note": "Crea e collega la nota figlia \"{{term}}\"",
|
||||
"insert-external-link": "Inserisci il collegamento esterno a \"{{term}}\"",
|
||||
"clear-text-field": "Pulisci il campo di testo",
|
||||
"show-recent-notes": "Mostra le note recenti",
|
||||
"full-text-search": "Ricerca full text"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "La nota è stata eliminata.",
|
||||
"quick-edit": "Modifica veloce"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "Crea una nota figlia e aggiungila alla mappa",
|
||||
"create-child-note-instruction": "Clicca sulla mappa per creare una nuova nota qui o premi Escape per uscire.",
|
||||
"unable-to-load-map": "Impossibile caricare la mappa."
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "Apri la posizione",
|
||||
"remove-from-map": "Rimuovi dalla mappa",
|
||||
"add-note": "Aggiungi un marcatore in questa posizione"
|
||||
},
|
||||
"debug": {
|
||||
"debug": "Debug"
|
||||
},
|
||||
"database_anonymization": {
|
||||
"light_anonymization": "Anonimizzazione parziale",
|
||||
"title": "Anonimizzazione del Database",
|
||||
"full_anonymization": "Anonimizzazione completa",
|
||||
"full_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà (rimuove tutti i contenuti delle note, lasciando solo la struttura e qualche metadato non sensibile) per condividerlo online allo scopo di debugging, senza paura di far trapelare i tuoi dati personali.",
|
||||
"save_fully_anonymized_database": "Salva il database completamente anonimizzato",
|
||||
"light_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà in parzialmente — in particolare, solo il contenuto delle note sarà rimosso, ma i titoli e gli attributi rimarranno. Inoltre, note con script personalizzati JS di frontend/backend e widget personalizzati lasciando rimarranno. Ciò mette a disposizione più contesto per il debug dei problemi.",
|
||||
"choose_anonymization": "Puoi decidere da solo se fornire un database completamente o parzialmente anonimizzato. Anche un database completamente anonimizzato è molto utile, sebbene in alcuni casi i database parzialmente anonimizzati possono accelerare il processo di identificazione dei bug e la loro correzione.",
|
||||
"no_anonymized_database_yet": "Nessun database ancora anonimizzato.",
|
||||
"save_lightly_anonymized_database": "Salva il database parzialmente anonimizzato",
|
||||
"successfully_created_fully_anonymized_database": "Database completamente anonimizzato creato in {{anonymizedFilePath}}",
|
||||
"successfully_created_lightly_anonymized_database": "Database parzialmente anonimizzato creato in {{anonymizedFilePath}}"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "Per favore scarica la versione ARM64",
|
||||
"continue_anyway": "Continua Comunque",
|
||||
"dont_show_again": "Non mostrare più questo avviso",
|
||||
"download_link": "Scarica la Versione Nativa"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"title": "Caratteristiche",
|
||||
"emoji_completion_enabled": "Abilita il completamento automatico delle Emoji",
|
||||
"note_completion_enabled": "Abilita il completamento automatico delle note"
|
||||
},
|
||||
"table_view": {
|
||||
"new-row": "Nuova riga",
|
||||
"new-column": "Nuova colonna",
|
||||
"sort-column-by": "Ordina per \"{{title}}\"",
|
||||
"sort-column-ascending": "Ascendente",
|
||||
"sort-column-descending": "Discendente",
|
||||
"sort-column-clear": "Cancella l'ordinamento",
|
||||
"hide-column": "Nascondi la colonna \"{{title}}\"",
|
||||
"show-hide-columns": "Mostra/nascondi le colonne",
|
||||
"row-insert-above": "Inserisci una riga sopra",
|
||||
"row-insert-below": "Inserisci una riga sotto"
|
||||
},
|
||||
"abstract_search_option": {
|
||||
"remove_this_search_option": "Rimuovi questa opzione di ricerca",
|
||||
"failed_rendering": "Opzione di ricerca di rendering non riuscita: {{dto}} con errore: {{error}} {{stack}}"
|
||||
},
|
||||
"ancestor": {
|
||||
"label": "Antenato"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Aggiungi etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"new_value_placeholder": "nuovo valore",
|
||||
"to_value": "al valore"
|
||||
},
|
||||
"update_label_value": {
|
||||
"to_value": "al valore",
|
||||
"label_name_placeholder": "nome dell'etichetta"
|
||||
},
|
||||
"delete_label": {
|
||||
"delete_label": "Elimina etichetta",
|
||||
"label_name_placeholder": "nome dell'etichetta",
|
||||
"label_name_title": "Sono ammessi i caratteri alfanumerici, il carattere di sottolineato e i due punti."
|
||||
},
|
||||
"tree-context-menu": {
|
||||
"move-to": "Muovi in...",
|
||||
"cut": "Taglia"
|
||||
},
|
||||
"electron_context_menu": {
|
||||
"cut": "Taglia",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"copy-link": "Copia collegamento",
|
||||
"paste-as-plain-text": "Incolla come testo semplice"
|
||||
},
|
||||
"editing": {
|
||||
"editor_type": {
|
||||
"multiline-toolbar": "Mostra la barra degli strumenti su più linee se non entra."
|
||||
}
|
||||
},
|
||||
"edit_button": {
|
||||
"edit_this_note": "Modifica questa nota"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Scorciatoie"
|
||||
},
|
||||
"shared_switch": {
|
||||
"toggle-on-title": "Condividi la nota",
|
||||
"toggle-off-title": "Non condividere la nota"
|
||||
},
|
||||
"search_string": {
|
||||
"search_prefix": "Cerca:"
|
||||
},
|
||||
"attachment_detail": {
|
||||
"open_help_page": "Apri la pagina di aiuto sugli allegati"
|
||||
},
|
||||
"search_definition": {
|
||||
"ancestor": "antenato",
|
||||
"debug": "debug",
|
||||
"action": "azione",
|
||||
"add_search_option": "Aggiungi un opzione di ricerca:",
|
||||
"search_string": "cerca la stringa",
|
||||
"limit": "limite"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Chiudi"
|
||||
},
|
||||
"board_view": {
|
||||
"insert-below": "Inserisci sotto",
|
||||
"delete-column": "Elimina la colonna",
|
||||
"delete-column-confirmation": "Sei sicuro di vole eliminare questa colonna? Il corrispondente attributo sarà eliminato anche nelle note sotto questa colonna."
|
||||
},
|
||||
"backup": {
|
||||
"enable_weekly_backup": "Abilita le archiviazioni settimanali",
|
||||
"enable_monthly_backup": "Abilita le archiviazioni mensili",
|
||||
"backup_recommendation": "Si raccomanda di mantenere attive le archiviazioni, sebbene ciò possa rendere l'avvio dell'applicazione lento con database grandi e/o dispositivi di archiviazione lenti.",
|
||||
"backup_now": "Archivia adesso",
|
||||
"backup_database_now": "Archivia il database adesso",
|
||||
"existing_backups": "Backup esistenti",
|
||||
"date-and-time": "Data e ora",
|
||||
"path": "Percorso",
|
||||
"database_backed_up_to": "Il database è stato archiviato in {{backupFilePath}}",
|
||||
"enable_daily_backup": "Abilita le archiviazioni giornaliere",
|
||||
"no_backup_yet": "Ancora nessuna archiviazione"
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Aggiorna"
|
||||
},
|
||||
"consistency_checks": {
|
||||
"find_and_fix_button": "Trova e correggi i problemi di coerenza",
|
||||
"finding_and_fixing_message": "In cerca e correzione dei problemi di coerenza...",
|
||||
"issues_fixed_message": "Qualsiasi problema di coerenza che possa essere stato trovato ora è corretto."
|
||||
},
|
||||
"database_integrity_check": {
|
||||
"check_button": "Controllo dell'integrità del database",
|
||||
"checking_integrity": "Controllo dell'integrità del database in corso...",
|
||||
"title": "Controllo di Integrità del database",
|
||||
"description": "Controllerà che il database non sia corrotto a livello SQLite. Può durare un po' di tempo, a seconda della grandezza del DB.",
|
||||
"integrity_check_failed": "Controllo di integrità fallito: {{results}}"
|
||||
},
|
||||
"sync": {
|
||||
"title": "Sincronizza",
|
||||
"force_full_sync_button": "Forza una sincronizzazione completa",
|
||||
"failed": "Sincronizzazione fallita: {{message}}"
|
||||
},
|
||||
"sync_2": {
|
||||
"config_title": "Configurazione per la Sincronizzazione",
|
||||
"proxy_label": "Server Proxy per la sincronizzazione (opzionale)",
|
||||
"test_title": "Test di sincronizzazione",
|
||||
"timeout": "Timeout per la sincronizzazione",
|
||||
"timeout_unit": "millisecondi",
|
||||
"save": "Salva",
|
||||
"help": "Aiuto"
|
||||
},
|
||||
"search_engine": {
|
||||
"save_button": "Salva"
|
||||
},
|
||||
"sql_table_schemas": {
|
||||
"tables": "Tabelle"
|
||||
},
|
||||
"tab_row": {
|
||||
"close_tab": "Chiudi la scheda",
|
||||
"add_new_tab": "Aggiungi una nuova scheda",
|
||||
"close": "Chiudi",
|
||||
"close_other_tabs": "Chiudi le altre schede",
|
||||
"close_right_tabs": "Chiudi le schede a destra",
|
||||
"close_all_tabs": "Chiudi tutte le schede",
|
||||
"reopen_last_tab": "Riapri l'ultima scheda chiusa",
|
||||
"move_tab_to_new_window": "Sposta questa scheda in una nuova finestra",
|
||||
"copy_tab_to_new_window": "Copia questa scheda in una nuova finestra",
|
||||
"new_tab": "Nuova scheda"
|
||||
},
|
||||
"toc": {
|
||||
"table_of_contents": "Sommario"
|
||||
},
|
||||
"table_of_contents": {
|
||||
"title": "Sommario"
|
||||
},
|
||||
"tray": {
|
||||
"title": "Vassoio di Sistema",
|
||||
"enable_tray": "Abilita il vassoio (Trilium necessita di essere riavviato affinché la modifica abbia effetto)"
|
||||
},
|
||||
"heading_style": {
|
||||
"title": "Stile dell'Intestazione",
|
||||
"plain": "Normale",
|
||||
"underline": "Sottolineato",
|
||||
"markdown": "Stile Markdown"
|
||||
},
|
||||
"highlights_list": {
|
||||
"title": "Punti salienti"
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Punti salienti",
|
||||
"options": "Opzioni"
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Ricerca rapida",
|
||||
"searching": "Ricerca in corso..."
|
||||
}
|
||||
}
|
||||
23
apps/client/src/translations/ja/translation.json
Normal file
23
apps/client/src/translations/ja/translation.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Trilium Notesについて",
|
||||
"close": "閉じる",
|
||||
"homepage": "ホームページ:",
|
||||
"app_version": "アプリのヴァージョン:",
|
||||
"db_version": "データベースのヴァージョン:",
|
||||
"sync_version": "同期のヴァージョン:",
|
||||
"build_date": "Build の日時:",
|
||||
"build_revision": "Build のヴァージョン:",
|
||||
"data_directory": "データの場所:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "致命的なエラー",
|
||||
"message": "致命的なエラーのせいでアプリをスタートできません:\n\n{{message}}\n\nおそらくスクリプトが予期しないバグを含んでいると思われます。アプリをセーフモードでスタートしてみて下さい。"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "ウィジェットを初期化できませんでした",
|
||||
"message-custom": "ノートID”{{id}}”, ノートタイトル “{{title}}” のカスタムウィジェットを初期化できませんでした:\n\n{{message}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,416 @@
|
||||
{
|
||||
"revisions": {
|
||||
"delete_button": ""
|
||||
},
|
||||
"code_block": {
|
||||
"theme_none": "Sem destaque de sintaxe",
|
||||
"theme_group_light": "Temas claros",
|
||||
"theme_group_dark": "Temas escuros"
|
||||
}
|
||||
"code_block": {
|
||||
"theme_none": "Sem destaque de sintaxe",
|
||||
"theme_group_light": "Temas claros",
|
||||
"theme_group_dark": "Temas escuros"
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre o Trilium Notes",
|
||||
"close": "Fechar",
|
||||
"homepage": "Página inicial:",
|
||||
"app_version": "Versão do App:",
|
||||
"db_version": "Versão do db:",
|
||||
"sync_version": "Versão de sincronização:",
|
||||
"build_date": "Data de compilação:",
|
||||
"build_revision": "Revisão da compilação:",
|
||||
"data_directory": "Diretório de dados:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Erro crítico",
|
||||
"message": "Ocorreu um erro crítico que impede a inicialização do aplicativo cliente:\n\n{{message}}\n\nIsso provavelmente foi causado por um script que falhou de maneira inesperada. Tente iniciar o aplicativo no modo seguro e resolva o problema."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Falha ao inicializar um widget",
|
||||
"message-custom": "O widget personalizado da nota com ID \"{{id}}\", intitulada \"{{title}}\", não pôde ser inicializado devido a:\n\n{{message}}",
|
||||
"message-unknown": "Widget desconhecido não pôde ser inicializado devido a:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Falha para carregar o script customizado",
|
||||
"message": "O script da nota com ID \"{{id}}\", intitulada \"{{title}}\", não pôde ser executado devido a:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Adicionar link",
|
||||
"help_on_links": "Ajuda sobre links",
|
||||
"close": "Fechar",
|
||||
"note": "Nota",
|
||||
"search_note": "pesquisar nota pelo nome",
|
||||
"link_title_mirrors": "o título do link reflete o título atual da nota",
|
||||
"link_title_arbitrary": "o título do link pode ser alterado livremente",
|
||||
"link_title": "Titulo do link",
|
||||
"button_add_link": "Adicionar link <kbd>enter</kbd>"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"close": "Fechar",
|
||||
"prefix": "Prefixo: ",
|
||||
"save": "Salvar",
|
||||
"edit_branch_prefix": "Editar Prefixo do Branch",
|
||||
"help_on_tree_prefix": "Ajuda sobre o prefixo da árvore de notas",
|
||||
"branch_prefix_saved": "O prefixo de ramificação foi salvo."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Ações em massa",
|
||||
"close": "Fechar",
|
||||
"affected_notes": "Notas Afetadas",
|
||||
"include_descendants": "Incluir notas filhas das notas selecionadas",
|
||||
"available_actions": "Ações disponíveis",
|
||||
"chosen_actions": "Ações selecionadas",
|
||||
"execute_bulk_actions": "Executar ações em massa",
|
||||
"bulk_actions_executed": "As ações em massa foram concluídas com sucesso.",
|
||||
"none_yet": "Nenhuma até agora... adicione uma ação clicando em uma das disponíveis acima.",
|
||||
"labels": "Etiquetas",
|
||||
"relations": "Relações",
|
||||
"notes": "Notas",
|
||||
"other": "Outros"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Clonar notas para...",
|
||||
"close": "Fechar",
|
||||
"help_on_links": "Ajuda sobre links",
|
||||
"notes_to_clone": "Notas para clonar",
|
||||
"search_for_note_by_its_name": "pesquisar nota pelo nome",
|
||||
"cloned_note_prefix_title": "A nota clonada aparecerá na árvore de notas com o prefixo fornecido",
|
||||
"prefix_optional": "Prefixo (opcional)",
|
||||
"no_path_to_clone_to": "Nenhum caminho para clonar.",
|
||||
"target_parent_note": "Nota pai-alvo",
|
||||
"clone_to_selected_note": "Clonar para a nota selecionada <kbd>enter</kbd>",
|
||||
"note_cloned": "A nota \"{{clonedTitle}}\" foi clonada para \"{{targetTitle}}\""
|
||||
},
|
||||
"ai_llm": {
|
||||
"n_notes_queued_0": "{{ count }} nota enfileirada para indexação",
|
||||
"n_notes_queued_1": "{{ count }} notas enfileiradas para indexação",
|
||||
"n_notes_queued_2": "{{ count }} notas enfileiradas para indexação",
|
||||
"notes_indexed_0": "{{ count }} nota indexada",
|
||||
"notes_indexed_1": "{{ count }} notas indexadas",
|
||||
"notes_indexed_2": "{{ count }} notas indexadas"
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Confirmação",
|
||||
"close": "Fechar",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "OK",
|
||||
"are_you_sure_remove_note": "Tem certeza de que deseja remover a nota '{{title}}' do mapa de relações? ",
|
||||
"if_you_dont_check": "Se você não marcar isso, a nota será removida apenas do mapa de relações.",
|
||||
"also_delete_note": "Também excluir a nota"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Excluir pré-visualização de notas",
|
||||
"close": "Fechar",
|
||||
"delete_all_clones_description": "Excluir também todos os clones (pode ser desfeito em alterações recentes)",
|
||||
"erase_notes_description": "A exclusão normal (suave) apenas marca as notas como excluídas, permitindo que sejam recuperadas (no diálogo de alterações recentes) dentro de um período de tempo. Se esta opção for marcada, as notas serão apagadas imediatamente e não será possível restaurá-las.",
|
||||
"erase_notes_warning": "Apagar notas permanentemente (não pode ser desfeito), incluindo todos os clones. Isso forçará o recarregamento do aplicativo.",
|
||||
"notes_to_be_deleted": "As seguintes notas serão excluídas ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nenhuma nota será excluída (apenas os clones).",
|
||||
"broken_relations_to_be_deleted": "As seguintes relações serão quebradas e excluídas ({{- relationCount}})",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "A nota {{- note}} (a ser excluída) está referenciada pela relação {{- relation}} originada de {{- source}}."
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "Exportar nota",
|
||||
"close": "Fechar",
|
||||
"export_type_subtree": "Esta nota e todos os seus descendentes",
|
||||
"format_html": "HTML – recomendado, pois mantém toda a formatação",
|
||||
"format_html_zip": "HTML em arquivo ZIP – recomendado, pois isso preserva toda a formatação.",
|
||||
"format_markdown": "Markdown – isso preserva a maior parte da formatação.",
|
||||
"format_opml": "OPML - formato de intercâmbio de outliners apenas para texto. Formatação, imagens e arquivos não estão incluídos.",
|
||||
"opml_version_1": "OPML v1.0 – apenas texto simples",
|
||||
"opml_version_2": "OPML v2.0 – permite também HTML",
|
||||
"export_type_single": "Apenas esta nota, sem seus descendentes",
|
||||
"export": "Exportar",
|
||||
"choose_export_type": "Por favor, escolha primeiro o tipo de exportação",
|
||||
"export_status": "Status da exportação",
|
||||
"export_in_progress": "Exportação em andamento: {{progressCount}}",
|
||||
"export_finished_successfully": "Exportação concluída com sucesso.",
|
||||
"format_pdf": "PDF – para impressão ou compartilhamento."
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "Ajuda (a documentação completa está disponível <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
|
||||
"close": "Fechar",
|
||||
"noteNavigation": "Navegação de notas",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> – subir/descer na lista de notas",
|
||||
"collapseExpand": "<kbd>ESQUERDA</kbd>, <kbd>DIREITA</kbd> – recolher/expandir nó",
|
||||
"notSet": "não definido",
|
||||
"goBackForwards": "voltar / avançar no histórico",
|
||||
"showJumpToNoteDialog": "mostrar diálogo <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Ir para\"</a>",
|
||||
"scrollToActiveNote": "rolar até a nota atual",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> – ir para a nota pai",
|
||||
"collapseWholeTree": "recolher toda a árvore de notas",
|
||||
"collapseSubTree": "recolher subárvore",
|
||||
"tabShortcuts": "Atalhos de abas",
|
||||
"newTabNoteLink": "<kbd>Ctrl+clique</kbd> – (ou <kbd>clique com o botão do meio do mouse</kbd>) em um link de nota abre a nota em uma nova aba",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+clique</kbd> – (ou <kbd>Shift+clique com o botão do meio do mouse</kbd>) em um link de nota abre e ativa a nota em uma nova aba",
|
||||
"onlyInDesktop": "Apenas na versão para desktop (compilação Electron)",
|
||||
"openEmptyTab": "abrir aba vazia",
|
||||
"closeActiveTab": "fechar aba ativa",
|
||||
"activateNextTab": "ativar próxima aba",
|
||||
"activatePreviousTab": "ativar aba anterior",
|
||||
"creatingNotes": "Criando notas",
|
||||
"createNoteAfter": "criar nova nota após a nota atual",
|
||||
"createNoteInto": "criar nova subnota dentro da nota atual",
|
||||
"editBranchPrefix": "editar <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefixo</a> do clone da nota ativa",
|
||||
"movingCloningNotes": "Movendo / clonando notas",
|
||||
"moveNoteUpDown": "mover nota para cima/baixo na lista de notas",
|
||||
"moveNoteUpHierarchy": "mover nota para cima na hierarquia",
|
||||
"multiSelectNote": "selecionar múltiplas notas acima/abaixo",
|
||||
"selectAllNotes": "selecionar todas as notas no nível atual",
|
||||
"selectNote": "<kbd>Shift+clique</kbd> - selecionar nota",
|
||||
"copyNotes": "copiar nota ativa (ou seleção atual) para a área de transferência (usado para <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">clonar</a>)",
|
||||
"cutNotes": "recortar nota atual (ou seleção atual) para a área de transferência (usado para mover notas)",
|
||||
"pasteNotes": "colar nota(s) como subnota dentro da nota ativa (o que pode ser mover ou clonar dependendo se foi copiado ou recortado para a área de transferência)",
|
||||
"deleteNotes": "excluir nota / subárvore",
|
||||
"editingNotes": "Edição de notas",
|
||||
"editNoteTitle": "no painel de árvore, a navegação mudará do painel de árvore para o título da nota. Pressionar Enter no título da nota mudará o foco para o editor de texto. <kbd>Ctrl+.</kbd> mudará o foco de volta do editor para o painel de árvore.",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - criar / editar link externo",
|
||||
"createInternalLink": "criar link interno",
|
||||
"followLink": "seguir link sob o cursor",
|
||||
"insertDateTime": "inserir data e hora atual na posição do cursor",
|
||||
"jumpToTreePane": "ir para a árvore de notas e rolar até a nota ativa",
|
||||
"markdownAutoformat": "Autoformatação estilo Markdown",
|
||||
"headings": "<code>##</code>, <code>###</code>, <code>####</code> etc. seguidos de espaço para títulos",
|
||||
"bulletList": "<code>*</code> ou <code>-</code> seguidos de espaço para lista com marcadores",
|
||||
"numberedList": "<code>1.</code> ou <code>1)</code> seguidos de espaço para lista numerada",
|
||||
"blockQuote": "comece uma linha com <code>></code> seguido de espaço para citação em bloco",
|
||||
"troubleshooting": "Solução de problemas",
|
||||
"reloadFrontend": "recarregar o frontend do Trilium",
|
||||
"showDevTools": "mostrar ferramentas de desenvolvedor",
|
||||
"showSQLConsole": "mostrar console SQL",
|
||||
"other": "Outros",
|
||||
"quickSearch": "focar no campo de pesquisa rápida",
|
||||
"inPageSearch": "pesquisa na página"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Importar para a nota",
|
||||
"close": "Fechar",
|
||||
"chooseImportFile": "Escolher arquivo para importar",
|
||||
"importDescription": "O conteúdo do(s) arquivo(s) selecionado(s) será importado como nota(s) filha(s) em",
|
||||
"options": "Opções",
|
||||
"safeImportTooltip": "Arquivos de exportação Trilium<code> .zip</code> podem conter scripts executáveis que podem apresentar comportamentos prejudiciais. A importação segura desativará a execução automática de todos os scripts importados. Desmarque “Importação segura” apenas se o arquivo de importação contiver scripts executáveis e você confiar totalmente no conteúdo do arquivo importado.",
|
||||
"safeImport": "Importação segura",
|
||||
"explodeArchivesTooltip": "Se esta opção estiver marcada, o Trilium irá ler arquivos <code>.zip</code>, <code>.enex</code> e <code>.opml</code> e criar notas a partir dos arquivos contidos nesses arquivos compactados. Se estiver desmarcada, o Trilium irá anexar os próprios arquivos compactados à nota.",
|
||||
"explodeArchives": "Ler conteúdos de arquivos <code>.zip</code>, <code>.enex</code> e <code>.opml</code>.",
|
||||
"shrinkImagesTooltip": "<p>Se você marcar esta opção, o Trilium tentará reduzir o tamanho das imagens importadas por meio de escala e otimização, o que pode afetar a qualidade visual das imagens. Se desmarcada, as imagens serão importadas sem alterações.</p><p>Isso não se aplica a importações de arquivos <code>.zip</code> com metadados, pois presume-se que esses arquivos já estejam otimizados.</p>",
|
||||
"shrinkImages": "Reduzir imagens",
|
||||
"textImportedAsText": "Importar arquivos HTML, Markdown e TXT como notas de texto caso não esteja claro pelos metadados",
|
||||
"codeImportedAsCode": "Importar arquivos de código reconhecidos (por exemplo, <code>.json</code>) como notas de código caso não esteja claro pelos metadados",
|
||||
"replaceUnderscoresWithSpaces": "Substituir sublinhados por espaços nos nomes das notas importadas",
|
||||
"import": "Importar",
|
||||
"failed": "Falha na importação: {{message}}.",
|
||||
"html_import_tags": {
|
||||
"title": "Tags de importação HTML",
|
||||
"description": "Configurar quais tags HTML devem ser preservadas ao importar notas. As tags que não estiverem nesta lista serão removidas durante a importação. Algumas tags (como 'script') são sempre removidas por motivos de segurança.",
|
||||
"placeholder": "Digite as tags HTML, uma por linha",
|
||||
"reset_button": "Redefinir para lista padrão"
|
||||
},
|
||||
"import-status": "Status da importação",
|
||||
"in-progress": "Importação em andamento: {{progress}}",
|
||||
"successful": "Importação concluída com sucesso."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Incluir nota",
|
||||
"close": "Fechar",
|
||||
"label_note": "Nota",
|
||||
"placeholder_search": "pesquisar nota pelo nome",
|
||||
"box_size_prompt": "Dimensão da caixa da nota incluída:",
|
||||
"box_size_small": "pequeno (~ 10 linhas)",
|
||||
"box_size_medium": "médio (~ 30 linhas)",
|
||||
"box_size_full": "completo (a caixa exibe o texto completo)",
|
||||
"button_include": "Incluir nota <kbd>enter</kbd>"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Mensagem informativa",
|
||||
"closeButton": "Fechar",
|
||||
"okButton": "OK"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Pesquise uma nota pelo nome ou digite > para comandos...",
|
||||
"close": "Fechar",
|
||||
"search_button": "Pesquisar em texto completo <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Importar Markdown",
|
||||
"close": "Fechar",
|
||||
"modal_body_text": "Por motivos de segurança (sandbox do navegador), o JavaScript não pode acessar diretamente a área de transferência. Por favor, cole o conteúdo Markdown na área de texto abaixo e clique em Importar",
|
||||
"import_button": "Importar Ctrl+Enter",
|
||||
"import_success": "O conteúdo Markdown foi importado para o documento."
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Mover notas para...",
|
||||
"close": "Fechar",
|
||||
"notes_to_move": "Notas para mover",
|
||||
"target_parent_note": "Nota pai-alvo",
|
||||
"search_placeholder": "pesquisar nota pelo nome",
|
||||
"move_button": "Mover para a nota selecionada <kbd>enter</kbd>",
|
||||
"error_no_path": "Nenhum caminho para mover.",
|
||||
"move_success_message": "As notas selecionadas foram movidas para "
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"change_path_prompt": "Alterar onde criar a nova nota:",
|
||||
"search_placeholder": "buscar caminho pelo nome (valor padrão se não for preenchido)",
|
||||
"modal_title": "Escolher tipo de nota",
|
||||
"close": "Fechar",
|
||||
"modal_body": "Escolha o tipo/modelo da nova nota:",
|
||||
"templates": "Modelos:"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "A senha não está definida",
|
||||
"close": "Fechar",
|
||||
"body1": "Notas protegidas são criptografadas usando uma senha do usuário, mas a senha ainda não foi definida.",
|
||||
"body2": "Para poder proteger notas, clique <a class=\"open-password-options-button\" href=\"javascript:\">aqui</a> para abrir a caixa de diálogo de Opções e definir sua senha."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Prompt",
|
||||
"close": "Fechar",
|
||||
"ok": "OK <kbd>enter</kbd>",
|
||||
"defaultTitle": "Prompt"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"modal_title": "Sessão Protegida",
|
||||
"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>"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Alterações recentes",
|
||||
"erase_notes_button": "Remover permanentemente as notas excluídas agora",
|
||||
"close": "Fechar",
|
||||
"deleted_notes_message": "As notas excluídas foram removidas permanentemente.",
|
||||
"no_changes_message": "Nenhuma alteração ainda...",
|
||||
"undelete_link": "Restaurar",
|
||||
"confirm_undelete": "Você deseja restaurar esta nota e suas subnotas?"
|
||||
},
|
||||
"revisions": {
|
||||
"note_revisions": "Versões da nota",
|
||||
"delete_all_revisions": "Excluir todas as versões desta nota",
|
||||
"delete_all_button": "Excluir todas as versões",
|
||||
"help_title": "Ajuda sobre as versões da nota",
|
||||
"close": "Fechar",
|
||||
"revision_last_edited": "Esta versão foi editada pela última vez em {{date}}",
|
||||
"confirm_delete_all": "Você quer excluir todas as versões desta nota?",
|
||||
"no_revisions": "Ainda não há versões para esta nota...",
|
||||
"restore_button": "Recuperar",
|
||||
"confirm_restore": "Deseja restaurar esta versão? Isso irá substituir o título e o conteúdo atuais da nota por esta versão.",
|
||||
"delete_button": "Excluir",
|
||||
"confirm_delete": "Deseja excluir esta versão?",
|
||||
"revisions_deleted": "As versões da nota foram removidas.",
|
||||
"revision_restored": "A versão da nota foi restaurada.",
|
||||
"revision_deleted": "A versão da nota foi excluída.",
|
||||
"snapshot_interval": "Intervalo de captura das versões da nota: {{seconds}}s.",
|
||||
"maximum_revisions": "Limite de capturas das versões da nota: {{number}}.",
|
||||
"settings": "Configurações de versões da nota",
|
||||
"download_button": "Download",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Tamanho do arquivo:",
|
||||
"preview": "Visualizar:",
|
||||
"preview_not_available": "A visualização não está disponível para este tipo de nota."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "Ordenar notas filhas por...",
|
||||
"close": "Fechar",
|
||||
"sorting_criteria": "Critérios de ordenação",
|
||||
"title": "título",
|
||||
"date_created": "data de criação",
|
||||
"date_modified": "data de modificação",
|
||||
"sorting_direction": "Direção de ordenação",
|
||||
"ascending": "crescente",
|
||||
"descending": "decrescente",
|
||||
"folders": "Pastas",
|
||||
"sort_folders_at_top": "colocar pastas no topo",
|
||||
"natural_sort": "Ordenação Natural",
|
||||
"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>"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Enviar anexos para a nota",
|
||||
"close": "Fechar",
|
||||
"choose_files": "Escolher arquivos",
|
||||
"files_will_be_uploaded": "Os arquivos serão enviados como anexos para",
|
||||
"options": "Opções",
|
||||
"shrink_images": "Reduzir imagens",
|
||||
"upload": "Enviar",
|
||||
"tooltip": "Se você marcar esta opção, o Trilium tentará reduzir as imagens enviadas redimensionando e otimizando, o que pode afetar a qualidade visual percebida. Se desmarcada, as imagens serão enviadas sem alterações."
|
||||
},
|
||||
"attribute_detail": {
|
||||
"attr_detail_title": "Título Detalhado do Atributo",
|
||||
"close_button_title": "Cancelar alterações e fechar",
|
||||
"attr_is_owned_by": "O atributo pertence a",
|
||||
"attr_name_title": "O nome do atributo pode ser composto apenas por caracteres alfanuméricos, dois-pontos e sublinhado",
|
||||
"name": "Nome",
|
||||
"value": "Valor",
|
||||
"target_note_title": "Relação é uma conexão nomeada entre a nota de origem e a nota de destino.",
|
||||
"target_note": "Nota de destino",
|
||||
"promoted_title": "O atributo promovido é exibido de forma destacada na nota.",
|
||||
"promoted": "Promovido",
|
||||
"promoted_alias_title": "O nome a ser exibido na interface de atributos promovidos.",
|
||||
"promoted_alias": "Alias",
|
||||
"multiplicity_title": "Multiplicidade define quantos atributos com o mesmo nome podem ser criados — no máximo 1 ou mais de 1.",
|
||||
"multiplicity": "Multiplicidade",
|
||||
"single_value": "Valor único",
|
||||
"multi_value": "Valor múltiplo",
|
||||
"label_type_title": "O tipo do rótulo ajudará o Trilium a escolher a interface adequada para inserir o valor do rótulo.",
|
||||
"label_type": "Tipo",
|
||||
"text": "Texto",
|
||||
"number": "Número",
|
||||
"boolean": "Booleano",
|
||||
"date": "Data",
|
||||
"date_time": "Data e Hora",
|
||||
"time": "Hora",
|
||||
"url": "URL",
|
||||
"precision_title": "Qual número de dígitos após o ponto decimal deve estar disponível na interface de configuração de valor.",
|
||||
"precision": "Precisão",
|
||||
"digits": "dígitos",
|
||||
"inverse_relation_title": "Configuração opcional para definir a qual relação esta é oposta. Exemplo: Pai - Filho são relações inversas entre si.",
|
||||
"inverse_relation": "Relação inversa",
|
||||
"inheritable_title": "O atributo herdável será transmitido para todos os descendentes deste ramo.",
|
||||
"inheritable": "Herdável",
|
||||
"save_and_close": "Salvar e fechar <kbd>Ctrl+Enter</kbd>",
|
||||
"delete": "Excluir",
|
||||
"related_notes_title": "Outras notas com este rótulo",
|
||||
"more_notes": "Mais notas",
|
||||
"label": "Detalhe do rótulo",
|
||||
"label_definition": "Detalhe da definição do rótulo",
|
||||
"relation": "Detalhe da relação",
|
||||
"relation_definition": "Detalhe da definição da relação",
|
||||
"disable_versioning": "desativa a versão automática. Útil, por exemplo, para notas grandes, mas sem importância – como grandes bibliotecas JS usadas para scripts",
|
||||
"calendar_root": "marca a nota que deve ser usada como raiz para notas diárias. Apenas uma deve ser marcada assim.",
|
||||
"archived": "notas com este rótulo não serão exibidas por padrão nos resultados de busca (também nos diálogos Ir para, Adicionar link, etc).",
|
||||
"exclude_from_export": "notas (junto com sua subárvore) não serão incluídas em nenhuma exportação de notas",
|
||||
"run": "define em quais eventos o script deve ser executado. Os valores possíveis são:\n<ul>\n<li>frontendStartup - quando o frontend do Trilium inicia (ou é atualizado), mas não no celular.</li>\n<li>mobileStartup - quando o frontend do Trilium inicia (ou é atualizado), no celular.</li>\n<li>backendStartup - quando o backend do Trilium inicia</li>\n<li>hourly - executa uma vez por hora. Você pode usar o rótulo adicional <code>runAtHour</code> para especificar em qual hora.</li>\n<li>daily - executa uma vez por dia</li>\n</ul>",
|
||||
"run_on_instance": "Define em qual instância do Trilium isso deve ser executado. Por padrão, todas as instâncias.",
|
||||
"run_at_hour": "Em qual hora isso deve ser executado. Deve ser usado junto com <code>#run=hourly</code>. Pode ser definido várias vezes para executar mais de uma vez ao dia.",
|
||||
"disable_inclusion": "scripts com este rótulo não serão incluídos na execução do script pai.",
|
||||
"sorted": "mantém as notas filhas ordenadas alfabeticamente pelo título",
|
||||
"sort_direction": "ASC (padrão) ou DESC",
|
||||
"sort_folders_first": "Pastas (notas com filhos) devem ser ordenadas no topo",
|
||||
"top": "mantenha a nota fornecida no topo em seu pai (aplica-se apenas a pais ordenados)",
|
||||
"hide_promoted_attributes": "Ocultar atributos promovidos nesta nota",
|
||||
"read_only": "o editor está em modo somente leitura. Funciona apenas para notas de texto e código.",
|
||||
"auto_read_only_disabled": "notas de texto/código podem ser automaticamente configuradas para modo de leitura quando são muito grandes. Você pode desabilitar esse comportamento por nota adicionando este rótulo à nota",
|
||||
"app_css": "marca notas CSS que são carregadas no aplicativo Trilium e, portanto, podem ser usadas para modificar a aparência do Trilium.",
|
||||
"app_theme": "marca notas CSS que são temas completos do Trilium e, portanto, estão disponíveis nas opções do Trilium.",
|
||||
"app_theme_base": "defina como \"next\", \"next-light\" ou \"next-dark\" para usar o tema TriliumNext correspondente (auto, claro ou escuro) como base para um tema personalizado, em vez do tema legado.",
|
||||
"css_class": "o valor deste rótulo é então adicionado como classe CSS ao nó que representa a nota específica na árvore. Isso pode ser útil para temas avançados. Pode ser usado em notas de modelo.",
|
||||
"icon_class": "o valor deste rótulo é adicionado como uma classe CSS ao ícone na árvore, o que pode ajudar a distinguir visualmente as notas na árvore. Um exemplo pode ser bx bx-home – os ícones são retirados do boxicons. Pode ser usado em notas de modelo.",
|
||||
"page_size": "número de itens por página na listagem de notas",
|
||||
"custom_request_handler": "veja <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Manipulador de requisição personalizada</a>",
|
||||
"custom_resource_provider": "veja <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Manipulador de requisição personalizada</a>",
|
||||
"widget": "marca esta nota como um widget personalizado que será adicionado à árvore de componentes do Trilium",
|
||||
"workspace": "marca esta nota como um espaço de trabalho, o que permite fácil hoisting",
|
||||
"workspace_icon_class": "define a classe CSS do ícone box que será usada na aba quando esta nota for hoisted",
|
||||
"workspace_tab_background_color": "cor CSS usada na aba da nota quando esta nota é hoisted",
|
||||
"workspace_calendar_root": "Define a raiz do calendário por espaço de trabalho",
|
||||
"workspace_template": "Esta nota aparecerá na seleção de modelos disponíveis ao criar uma nova nota, mas apenas quando estiver destacada em um espaço de trabalho que contenha este modelo",
|
||||
"search_home": "novas notas de pesquisa serão criadas como filhas desta nota",
|
||||
"workspace_search_home": "novas notas de pesquisa serão criadas como filhas desta nota quando ela for destacada para algum ancestral desta nota de área de trabalho",
|
||||
"inbox": "localização padrão da caixa de entrada para novas notas – quando você cria uma nota usando o botão \"nova nota\" na barra lateral, as notas serão criadas como notas filhas na nota marcada com o rótulo <code>#inbox</code>.",
|
||||
"workspace_inbox": "local padrão da caixa de entrada para novas notas quando esta nota for destacada para algum ancestral desta nota de área de trabalho",
|
||||
"sql_console_home": "localização padrão das notas do console SQL",
|
||||
"bookmark_folder": "nota com este rótulo aparecerá nos favoritos como uma pasta (permitindo acesso aos seus filhos)",
|
||||
"share_hidden_from_tree": "esta nota está oculta na árvore de navegação à esquerda, mas ainda pode ser acessada via sua URL",
|
||||
"share_external_link": "a nota funcionará como um link para um site externo na árvore de compartilhamento"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1260
apps/client/src/translations/ru/translation.json
Normal file
1260
apps/client/src/translations/ru/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
517
apps/client/src/translations/sr/translation.json
Normal file
517
apps/client/src/translations/sr/translation.json
Normal file
@@ -0,0 +1,517 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "O Trilium Belеškama",
|
||||
"close": "Zatvori",
|
||||
"homepage": "Početna stranica:",
|
||||
"app_version": "Verzija aplikacije:",
|
||||
"db_version": "Verzija baze podataka:",
|
||||
"sync_version": "Verzija sinhronizacije:",
|
||||
"build_date": "Datum izgradnje:",
|
||||
"build_revision": "Revizija izgradnje:",
|
||||
"data_directory": "Direktorijum sa podacima:"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Kritična greška",
|
||||
"message": "Došlo je do kritične greške koja sprečava pokretanje klijentske aplikacije.\n\n{{message}}\n\nOva greška je najverovatnije izazvana neočekivanim problemom prilikom izvršavanja skripte. Pokušajte da pokrenete aplikaciju u bezbednom režimu i da pronađete šta izaziva grešku."
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Pokretanje vidžeta nije uspelo",
|
||||
"message-custom": "Prilagođeni viđet sa beleške sa ID-jem \"{{id}}\", nazivom \"{{title}}\" nije uspeo da se pokrene zbog:\n\n{{message}}",
|
||||
"message-unknown": "Nepoznati vidžet nije mogao da se pokrene zbog:\n\n{{message}}"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Pokretanje prilagođene skripte neuspešno",
|
||||
"message": "Skripta iz beleške sa ID-jem \"{{id}}\", naslovom \"{{title}}\" nije mogla da se izvrši zbog:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Dodaj link",
|
||||
"help_on_links": "Pomoć na linkovima",
|
||||
"close": "Zatvori",
|
||||
"note": "Beleška",
|
||||
"search_note": "potražite belešku po njenom imenu",
|
||||
"link_title_mirrors": "naziv linka preslikava trenutan naziv beleške",
|
||||
"link_title_arbitrary": "naziv linka se može proizvoljno menjati",
|
||||
"link_title": "Naziv linka",
|
||||
"button_add_link": "Dodaj link <kbd>enter</kbd>"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"edit_branch_prefix": "Izmeni prefiks grane",
|
||||
"help_on_tree_prefix": "Pomoć na prefiksu Drveta",
|
||||
"close": "Zatvori",
|
||||
"prefix": "Prefiks: ",
|
||||
"save": "Sačuvaj",
|
||||
"branch_prefix_saved": "Prefiks grane je sačuvan."
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "Grupne akcije",
|
||||
"close": "Zatvori",
|
||||
"affected_notes": "Pogođene beleške",
|
||||
"include_descendants": "Obuhvati potomke izabranih beleški",
|
||||
"available_actions": "Dostupne akcije",
|
||||
"chosen_actions": "Izabrane akcije",
|
||||
"execute_bulk_actions": "Izvrši grupne akcije",
|
||||
"bulk_actions_executed": "Grupne akcije su uspešno izvršene.",
|
||||
"none_yet": "Nijedna za sad... dodajte akciju tako što ćete pritisnuti na neku od dostupnih akcija iznad.",
|
||||
"labels": "Oznake",
|
||||
"relations": "Odnosi",
|
||||
"notes": "Beleške",
|
||||
"other": "Ostalo"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Klonirajte beleške u...",
|
||||
"close": "Zatvori",
|
||||
"help_on_links": "Pomoć na linkovima",
|
||||
"notes_to_clone": "Beleške za kloniranje",
|
||||
"target_parent_note": "Ciljna nadređena beleška",
|
||||
"search_for_note_by_its_name": "potražite belešku po njenom imenu",
|
||||
"cloned_note_prefix_title": "Klonirana beleška će biti prikazana u drvetu beleški sa datim prefiksom",
|
||||
"prefix_optional": "Prefiks (opciono)",
|
||||
"clone_to_selected_note": "Kloniranje u izabranu belešku <kbd>enter</kbd>",
|
||||
"no_path_to_clone_to": "Nema putanje za kloniranje.",
|
||||
"note_cloned": "Beleška \"{{clonedTitle}}\" je klonirana u \"{{targetTitle}}\""
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "Potvrda",
|
||||
"close": "Zatvori",
|
||||
"cancel": "Otkaži",
|
||||
"ok": "U redu",
|
||||
"are_you_sure_remove_note": "Da li ste sigurni da želite da uklonite belešku \"{{title}}\" iz mape odnosa? ",
|
||||
"if_you_dont_check": "Ako ne izaberete ovo, beleška će biti uklonjena samo sa mape odnosa.",
|
||||
"also_delete_note": "Takođe obriši belešku"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Obriši pregled beleške",
|
||||
"close": "Zatvori",
|
||||
"delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)",
|
||||
"erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.",
|
||||
"erase_notes_warning": "Trajno obriši beleške (ne može se opozvati), uključujući sve klonove. Ovo će prisiliti aplikaciju da se ponovo pokrene.",
|
||||
"notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})",
|
||||
"no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).",
|
||||
"broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})",
|
||||
"cancel": "Otkaži",
|
||||
"ok": "U redu",
|
||||
"deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}."
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "Izvezi belešku",
|
||||
"close": "Zatvori",
|
||||
"export_type_subtree": "Ova beleška i svi njeni potomci",
|
||||
"format_html": "HTML - preporučuje se jer čuva formatiranje",
|
||||
"format_html_zip": "HTML u ZIP arhivi - ovo se preporučuje jer se na taj način čuva celokupno formatiranje.",
|
||||
"format_markdown": "Markdown - ovo čuva većinu formatiranja.",
|
||||
"format_opml": "OPML - format za razmenu okvira samo za tekst. Formatiranje, slike i datoteke nisu uključeni.",
|
||||
"opml_version_1": "OPML v1.0 - samo običan tekst",
|
||||
"opml_version_2": "OPML v2.0 - dozvoljava i HTML",
|
||||
"export_type_single": "Samo ovu belešku bez njenih potomaka",
|
||||
"export": "Izvoz",
|
||||
"choose_export_type": "Molimo vas da prvo izaberete tip izvoza",
|
||||
"export_status": "Status izvoza",
|
||||
"export_in_progress": "Izvoz u toku: {{progressCount}}",
|
||||
"export_finished_successfully": "Izvoz je uspešno završen.",
|
||||
"format_pdf": "PDF - za namene štampanja ili deljenja."
|
||||
},
|
||||
"help": {
|
||||
"fullDocumentation": "Pomoć (puna dokumentacija je dostupna <a class=\"external\" href=\"https://triliumnext.github.io/Docs/\">online</a>)",
|
||||
"close": "Zatvori",
|
||||
"noteNavigation": "Navigacija beleški",
|
||||
"goUpDown": "<kbd>UP</kbd>, <kbd>DOWN</kbd> - kretanje gore/dole u listi sa beleškama",
|
||||
"collapseExpand": "<kbd>LEFT</kbd>, <kbd>RIGHT</kbd> - sakupi/proširi čvor",
|
||||
"notSet": "nije podešeno",
|
||||
"goBackForwards": "idi u nazad/napred kroz istoriju",
|
||||
"showJumpToNoteDialog": "prikaži <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Idi na\" dijalog</a>",
|
||||
"scrollToActiveNote": "skroluj do aktivne beleške",
|
||||
"jumpToParentNote": "<kbd>Backspace</kbd> - idi do nadređene beleške",
|
||||
"collapseWholeTree": "sakupi celo drvo beleški",
|
||||
"collapseSubTree": "sakupi pod-drvo",
|
||||
"tabShortcuts": "Prečice na karticama",
|
||||
"newTabNoteLink": "<kbd>Ctrl+click</kbd> - (ili <kbd>middle mouse click</kbd>) na link beleške otvara belešku u novoj kartici",
|
||||
"newTabWithActivationNoteLink": "<kbd>Ctrl+Shift+click</kbd> - (ili <kbd>Shift+middle mouse click</kbd>) na link beleške otvara i aktivira belešku u novoj kartici",
|
||||
"onlyInDesktop": "Samo na dektop-u (Electron verzija)",
|
||||
"openEmptyTab": "otvori praznu karticu",
|
||||
"closeActiveTab": "zatvori aktivnu karticu",
|
||||
"activateNextTab": "aktiviraj narednu karticu",
|
||||
"activatePreviousTab": "aktiviraj prethodnu karticu",
|
||||
"creatingNotes": "Pravljenje beleški",
|
||||
"createNoteAfter": "napravi novu belešku nakon aktivne beleške",
|
||||
"createNoteInto": "napravi novu pod-belešku u aktivnoj belešci",
|
||||
"editBranchPrefix": "izmeni <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">prefiks</a> klona aktivne beleške",
|
||||
"movingCloningNotes": "Premeštanje / kloniranje beleški",
|
||||
"moveNoteUpDown": "pomeri belešku gore/dole u listi beleški",
|
||||
"moveNoteUpHierarchy": "pomeri belešku na gore u hijerarhiji",
|
||||
"multiSelectNote": "višestruki izbor beleški iznad/ispod",
|
||||
"selectAllNotes": "izaberi sve beleške u trenutnom nivou",
|
||||
"selectNote": "<kbd>Shift+click</kbd> - izaberi belešku",
|
||||
"copyNotes": "kopiraj aktivnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">kloniranje</a>)",
|
||||
"cutNotes": "iseci trenutnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za premeštanje beleški)",
|
||||
"pasteNotes": "nalepi belešku/e kao podbelešku u aktivnoj belešci (koja se ili premešta ili klonira u zavisnosti od toga da li je beleška kopirana ili isečena u privremenu memoriju)",
|
||||
"deleteNotes": "obriši belešku / podstablo",
|
||||
"editingNotes": "Izmena beleški",
|
||||
"editNoteTitle": "u ravni drveta će se prebaciti sa ravni drveta na naslov beleške. Ulaz sa naslova beleške će prebaciti fokus na uređivač teksta. <kbd>Ctrl+.</kbd> će se vratiti sa uređivača na ravan drveta.",
|
||||
"createEditLink": "<kbd>Ctrl+K</kbd> - napravi / izmeni spoljašnji link",
|
||||
"createInternalLink": "napravi unutrašnji link",
|
||||
"followLink": "prati link ispod kursora",
|
||||
"insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora",
|
||||
"jumpToTreePane": "idi na ravan stabla i pomeri se do aktivne beleške",
|
||||
"markdownAutoformat": "Autoformatiranje kao u Markdown-u",
|
||||
"headings": "<code>##</code>, <code>###</code>, <code>####</code> itd. praćeno razmakom za naslove",
|
||||
"bulletList": "<code>*</code> ili <code>-</code> praćeno razmakom za listu sa tačkama",
|
||||
"numberedList": "<code>1.</code> ili <code>1)</code> praćeno razmakom za numerisanu listu",
|
||||
"blockQuote": "započnite liniju sa <code>></code> praćeno sa razmakom za blok citat",
|
||||
"troubleshooting": "Rešavanje problema",
|
||||
"reloadFrontend": "ponovo učitaj Trilium frontend",
|
||||
"showDevTools": "prikaži alate za programere",
|
||||
"showSQLConsole": "prikaži SQL konzolu",
|
||||
"other": "Ostalo",
|
||||
"quickSearch": "fokus na unos za brzu pretragu",
|
||||
"inPageSearch": "pretraga unutar stranice"
|
||||
},
|
||||
"import": {
|
||||
"importIntoNote": "Uvezi u belešku",
|
||||
"close": "Zatvori",
|
||||
"chooseImportFile": "Izaberi datoteku za uvoz",
|
||||
"importDescription": "Sadržaj izabranih datoteka će biti uvezen kao podbeleške u",
|
||||
"options": "Opcije",
|
||||
"safeImportTooltip": "Trilium <code>.zip</code> izvozne datoteke mogu da sadrže izvršne skripte koje mogu imati štetno ponašanje. Bezbedan uvoz će deaktivirati automatsko izvršavanje svih uvezenih skripti. Isključite \"Bezbedan uvoz\" samo ako uvezena arhiva treba da sadrži izvršne skripte i ako potpuno verujete sadržaju uvezene datoteke.",
|
||||
"safeImport": "Bezbedan uvoz",
|
||||
"explodeArchivesTooltip": "Ako je ovo označeno onda će Trilium pročitati <code>.zip</code>, <code>.enex</code> i <code>.opml</code> datoteke i napraviti beleške od datoteka unutar tih arhiva. Ako nije označeno, Trilium će same arhive priložiti belešci.",
|
||||
"explodeArchives": "Pročitaj sadržaj <code>.zip</code>, <code>.enex</code> i <code>.opml</code> arhiva.",
|
||||
"shrinkImagesTooltip": "<p>Ako označite ovu opciju, Trilium će pokušati da smanji uvezene slike skaliranjem i optimizacijom što će možda uticati na kvalitet slike. Ako nije označeno, slike će biti uvezene bez promena.</p><p>Ovo se ne primenjuje na <code>.zip</code> uvoze sa metapodacima jer se tada podrazumeva da su te datoteke već optimizovane.</p>",
|
||||
"shrinkImages": "Smanji slike",
|
||||
"textImportedAsText": "Uvezi HTML, Markdown i TXT kao tekstualne beleške ako je nejasno iz metapodataka",
|
||||
"codeImportedAsCode": "Uvezi prepoznate datoteke sa kodom (poput <code>.json</code>) ako beleške sa kodom ako nije jasno iz metapodataka",
|
||||
"replaceUnderscoresWithSpaces": "Zameni podvlake sa razmacima u nazivima uvezenih beleški",
|
||||
"import": "Uvezi",
|
||||
"failed": "Uvoz nije uspeo: {{message}}.",
|
||||
"html_import_tags": {
|
||||
"title": "HTML oznake za uvoz",
|
||||
"description": "Podesite koje HTML oznake trebaju biti sačuvane kada se uvoze beleške. Oznake koje se ne nalaze na listi će biti uklonjene tokom uvoza. Pojedine oznake (poput 'script') se uvek uklanjaju zbog bezbednosti.",
|
||||
"placeholder": "Unesite HTML oznake, po jednu u svaki red",
|
||||
"reset_button": "Vrati na podrazumevanu listu"
|
||||
},
|
||||
"import-status": "Status uvoza",
|
||||
"in-progress": "Uvoz u toku: {{progress}}",
|
||||
"successful": "Uvoz je uspešno završen."
|
||||
},
|
||||
"include_note": {
|
||||
"dialog_title": "Uključi belešku",
|
||||
"close": "Zatvori",
|
||||
"label_note": "Beleška",
|
||||
"placeholder_search": "pretraži belešku po njenom imenu",
|
||||
"box_size_prompt": "Veličina kutije priložene beleške:",
|
||||
"box_size_small": "mala (~ 10 redova)",
|
||||
"box_size_medium": "srednja (~ 30 redova)",
|
||||
"box_size_full": "puna (kutija prikazuje ceo tekst)",
|
||||
"button_include": "Uključi belešku <kbd>enter</kbd>"
|
||||
},
|
||||
"info": {
|
||||
"modalTitle": "Informativna poruka",
|
||||
"closeButton": "Zatvori",
|
||||
"okButton": "U redu"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"search_placeholder": "Pretraži belešku po njenom imenu ili unesi > za komande...",
|
||||
"close": "Zatvori",
|
||||
"search_button": "Pretraga u punom tekstu <kbd>Ctrl+Enter</kbd>"
|
||||
},
|
||||
"markdown_import": {
|
||||
"dialog_title": "Uvoz za Markdown",
|
||||
"close": "Zatvori",
|
||||
"modal_body_text": "Zbog Sandbox-a pretraživača nije moguće direktno učitati privremenu memoriju iz JavaScript-a. Molimo vas da nalepite Markdown za uvoz u tekstualno polje ispod i kliknete na dugme za uvoz",
|
||||
"import_button": "Uvoz Ctrl+Enter",
|
||||
"import_success": "Markdown sadržaj je učitan u dokument."
|
||||
},
|
||||
"move_to": {
|
||||
"dialog_title": "Premesti beleške u ...",
|
||||
"close": "Zatvori",
|
||||
"notes_to_move": "Beleške za premeštanje",
|
||||
"target_parent_note": "Ciljana nadbeleška",
|
||||
"search_placeholder": "potraži belešku po njenom imenu",
|
||||
"move_button": "Pređi na izabranu belešku <kbd>enter</kbd>",
|
||||
"error_no_path": "Nema putanje za premeštanje.",
|
||||
"move_success_message": "Izabrane beleške su premeštene u "
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"change_path_prompt": "Promenite gde će se napraviti nova beleška:",
|
||||
"search_placeholder": "pretraži putanju po njenom imenu (podrazumevano ako je prazno)",
|
||||
"modal_title": "Izaberite tip beleške",
|
||||
"close": "Zatvori",
|
||||
"modal_body": "Izaberite tip beleške / šablon za novu belešku:",
|
||||
"templates": "Šabloni:"
|
||||
},
|
||||
"password_not_set": {
|
||||
"title": "Lozinka nije podešena",
|
||||
"close": "Zatvori",
|
||||
"body1": "Zaštićene beleške su enkriptovane sa korisničkom lozinkom, ali lozinka još uvek nije podešena.",
|
||||
"body2": "Za biste mogli da sačuvate beleške, kliknite <a class=\"open-password-options-button\" href=\"javascript:\">ovde</a> da otvorite dijalog sa Opcijama i podesite svoju lozinku."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Upit",
|
||||
"close": "Zatvori",
|
||||
"ok": "U redu <kbd>enter</kbd>",
|
||||
"defaultTitle": "Upit"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"modal_title": "Zaštićena sesija",
|
||||
"help_title": "Pomoć za Zaštićene beleške",
|
||||
"close_label": "Zatvori",
|
||||
"form_label": "Da biste nastavili sa traženom akcijom moraćete započeti zaštićenu sesiju tako što ćete uneti lozinku:",
|
||||
"start_button": "Započni zaštićenu sesiju <kbd>enter</kbd>"
|
||||
},
|
||||
"recent_changes": {
|
||||
"title": "Nedavne promene",
|
||||
"erase_notes_button": "Obriši izabrane beleške odmah",
|
||||
"close": "Zatvori",
|
||||
"deleted_notes_message": "Obrisane beleške su uklonjene.",
|
||||
"no_changes_message": "Još uvek nema izmena...",
|
||||
"undelete_link": "poništi brisanje",
|
||||
"confirm_undelete": "Da li želite da poništite brisanje ove beleške i njenih podbeleški?"
|
||||
},
|
||||
"revisions": {
|
||||
"note_revisions": "Revizije beleški",
|
||||
"delete_all_revisions": "Obriši sve revizije ove beleške",
|
||||
"delete_all_button": "Obriši sve revizije",
|
||||
"help_title": "Pomoć za Revizije beleški",
|
||||
"close": "Zatvori",
|
||||
"revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}",
|
||||
"confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?",
|
||||
"no_revisions": "Još uvek nema revizija za ovu belešku...",
|
||||
"restore_button": "Vrati",
|
||||
"confirm_restore": "Da li želite da vratite ovu reviziju? Ovo će prepisati trenutan naslov i sadržaj beleške sa ovom revizijom.",
|
||||
"delete_button": "Obriši",
|
||||
"confirm_delete": "Da li želite da obrišete ovu reviziju?",
|
||||
"revisions_deleted": "Revizije beleške su obrisane.",
|
||||
"revision_restored": "Revizija beleške je vraćena.",
|
||||
"revision_deleted": "Revizija beleške je obrisana.",
|
||||
"snapshot_interval": "Interval snimanja revizije beleške: {{seconds}}s.",
|
||||
"maximum_revisions": "Ograničenje broja slika revizije beleške: {{number}}.",
|
||||
"settings": "Podešavanja revizija beleški",
|
||||
"download_button": "Preuzmi",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Veličina datoteke:",
|
||||
"preview": "Pregled:",
|
||||
"preview_not_available": "Pregled nije dostupan za ovaj tip beleške."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"sort_children_by": "Sortiranje podbeleški po...",
|
||||
"close": "Zatvori",
|
||||
"sorting_criteria": "Kriterijum za sortiranje",
|
||||
"title": "naslov",
|
||||
"date_created": "datum kreiranja",
|
||||
"date_modified": "datum izmene",
|
||||
"sorting_direction": "Smer sortiranja",
|
||||
"ascending": "uzlazni",
|
||||
"descending": "silazni",
|
||||
"folders": "Fascikle",
|
||||
"sort_folders_at_top": "sortiraj fascikle na vrh",
|
||||
"natural_sort": "Prirodno sortiranje",
|
||||
"sort_with_respect_to_different_character_sorting": "sortiranje sa poštovanjem različitih pravila sortiranja karaktera i kolacija u različitim jezicima ili regionima.",
|
||||
"natural_sort_language": "Jezik za prirodno sortiranje",
|
||||
"the_language_code_for_natural_sort": "Kod jezika za prirodno sortiranje, npr. \"zh-CN\" za Kineski.",
|
||||
"sort": "Sortiraj <kbd>enter</kbd>"
|
||||
},
|
||||
"upload_attachments": {
|
||||
"upload_attachments_to_note": "Otpremite priloge uz belešku",
|
||||
"close": "Zatvori",
|
||||
"choose_files": "Izaberite datoteke",
|
||||
"files_will_be_uploaded": "Datoteke će biti otpremljene kao prilozi u",
|
||||
"options": "Opcije",
|
||||
"shrink_images": "Smanji slike",
|
||||
"upload": "Otpremi",
|
||||
"tooltip": "Ako je označeno, Trilium će pokušati da smanji otpremljene slike skaliranjem i optimizacijom što može uticati na kvalitet slike. Ako nije označeno, slike će biti otpremljene bez izmena."
|
||||
},
|
||||
"attribute_detail": {
|
||||
"attr_detail_title": "Naslov detalja atributa",
|
||||
"close_button_title": "Otkaži izmene i zatvori",
|
||||
"attr_is_owned_by": "Atribut je u vlasništvu",
|
||||
"attr_name_title": "Naziv atributa može biti sastavljen samo od alfanumeričkih znakova, dvotačke i donje crte",
|
||||
"name": "Naziv",
|
||||
"value": "Vrednost",
|
||||
"target_note_title": "Relacija je imenovana veza između izvorne beleške i ciljne beleške.",
|
||||
"target_note": "Ciljna beleška",
|
||||
"promoted_title": "Promovisani atribut je istaknut na belešci.",
|
||||
"promoted": "Promovisan",
|
||||
"promoted_alias_title": "Naziv koji će biti prikazan u korisničkom interfejsu promovisanih atributa.",
|
||||
"promoted_alias": "Pseudonim",
|
||||
"multiplicity_title": "Multiplicitet definiše koliko atributa sa istim nazivom se može napraviti - najviše 1 ili više od 1.",
|
||||
"multiplicity": "Multiplicitet",
|
||||
"single_value": "Jednostruka vrednost",
|
||||
"multi_value": "Višestruka vrednost",
|
||||
"label_type_title": "Tip oznake će pomoći Triliumu da izabere odgovarajući interfejs za unos vrednosti oznake.",
|
||||
"label_type": "Tip",
|
||||
"text": "Tekst",
|
||||
"number": "Broj",
|
||||
"boolean": "Boolean",
|
||||
"date": "Datum",
|
||||
"date_time": "Datum i vreme",
|
||||
"time": "Vreme",
|
||||
"url": "URL",
|
||||
"precision_title": "Broj cifara posle zareza treba biti dostupan u interfejsu za postavljanje vrednosti.",
|
||||
"precision": "Preciznost",
|
||||
"digits": "cifre",
|
||||
"inverse_relation_title": "Opciono podešavanje za definisanje kojoj relaciji je ova suprotna. Primer: Otac - Sin su inverzne relacije jedna drugoj.",
|
||||
"inverse_relation": "Inverzna relacija",
|
||||
"inheritable_title": "Atributi koji mogu da se nasleđuju će biti nasleđeni od strane svih potomaka unutar ovog stabla.",
|
||||
"inheritable": "Nasledno",
|
||||
"save_and_close": "Sačuvaj i zatvori <kbd>Ctrl+Enter</kbd>",
|
||||
"delete": "Obriši",
|
||||
"related_notes_title": "Druge beleške sa ovom oznakom",
|
||||
"more_notes": "Još beleški",
|
||||
"label": "Detalji oznake",
|
||||
"label_definition": "Detalji definicije oznake",
|
||||
"relation": "Detalji relacije",
|
||||
"relation_definition": "Detalji definicije relacije",
|
||||
"disable_versioning": "onemogućava auto-verzionisanje. Korisno za npr. velike, ali nebitne beleške - poput velikih JS biblioteka koje se koriste za skripte",
|
||||
"calendar_root": "obeležava belešku koju treba koristiti kao osnova za dnevne beleške. Samo jedna beleška treba da bude označena kao takva.",
|
||||
"archived": "beleške sa ovom oznakom neće biti podrazumevano vidljive u rezultatima pretrage (kao ni u dijalozima za Idi na, Dodaj link, itd.).",
|
||||
"exclude_from_export": "beleške (sa svojim podstablom) neće biti uključene u bilo koji izvoz beleški",
|
||||
"run": "definiše u kojim događajima se skripta pokreće. Moguće vrednosti su:\n<ul>\n<li>frontendStartup - kada se pokrene Trilium frontend (ili se osveži), ali ne na mobilnom uređaju.</li>\n<li>mobileStartup - kada se pokrene Trilium frontend (ili se osveži), na mobilnom uređaju..</li>\n<li>backendStartup - kada se Trilium backend pokrene</li>\n<li>hourly - pokreće se svaki sat. Može se koristiti dodatna oznaka <code>runAtHour</code> da se označi u kom satu.</li>\n<li>daily - pokreće se jednom dnevno</li>\n</ul>",
|
||||
"run_on_instance": "Definiše u kojoj instanci Trilium-a ovo treba da se pokreće. Podrazumevano podešavanje je na svim instancama.",
|
||||
"run_at_hour": "U kom satu ovo treba da se pokreće. Treba se koristiti zajedno sa <code>#run=hourly</code>. Može biti definisano više puta za više pokretanja u toku dana.",
|
||||
"disable_inclusion": "skripte sa ovom oznakom neće biti uključene u izvršavanju nadskripte.",
|
||||
"sorted": "čuva podbeleške sortirane alfabetski po naslovu",
|
||||
"sort_direction": "Uzlazno (podrazumevano) ili silazno",
|
||||
"sort_folders_first": "Fascikle (beleške sa podbeleškama) treba da budu sortirane na vrhu",
|
||||
"top": "zadrži datu belešku na vrhu njene nadbeleške (primenjuje se samo na sortiranim nadbeleškama)",
|
||||
"hide_promoted_attributes": "Sakrij promovisane atribute na ovoj belešci",
|
||||
"read_only": "uređivač je u režimu samo za čitanje. Radi samo za tekst i beleške sa kodom.",
|
||||
"auto_read_only_disabled": "beleške sa tekstom/kodom se mogu automatski podesiti u režim za čitanje kada su prevelike. Ovo ponašanje možete onemogućiti pojedinačno za belešku dodavanjem ove oznake na belešku",
|
||||
"app_css": "označava CSS beleške koje nisu učitane u Trilium aplikaciju i zbog toga se mogu koristiti za menjanje izgleda Triliuma.",
|
||||
"app_theme": "označava CSS beleške koje su pune Trilium teme i stoga su dostupne u Trilium podešavanjima.",
|
||||
"app_theme_base": "podesite na „sledeće“, „sledeće-svetlo“ ili „sledeće-tamno“ da biste koristili odgovarajuću TriliumNext temu (automatsku, svetlu ili tamnu) kao osnovu za prilagođenu temu, umesto podrazumevane teme.",
|
||||
"css_class": "vrednost ove oznake se zatim dodaje kao CSS klasa čvoru koji predstavlja datu belešku u stablu. Ovo može biti korisno za napredno temiranje. Može se koristiti u šablonima beleški.",
|
||||
"workspace": "označava ovu belešku kao radni prostor što omogućava lako podizanje",
|
||||
"workspace_icon_class": "definiše CSS klasu ikone okvira koja će se koristiti u kartici kada se podigne na ovoj belešci",
|
||||
"workspace_tab_background_color": "CSS boja korišćena u kartici beleške kada se prebaci na ovu belešku",
|
||||
"workspace_calendar_root": "Definiše koren kalendara za svaki radni prostor",
|
||||
"workspace_template": "Ova beleška će se pojaviti u izboru dostupnih šablona prilikom kreiranja nove beleške, ali samo kada se podigne u radni prostor koji sadrži ovaj šablon",
|
||||
"search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške",
|
||||
"workspace_search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške kada se podignu na nekog pretka ove beleške iz radnog prostora",
|
||||
"inbox": "podrazumevana lokacija u prijemnom sandučetu za nove beleške - kada kreirate belešku pomoću dugmeta „nova beleška“ u bočnoj traci, beleške će biti kreirane kao podbeleške u belešci označenoj sa oznakom <code>#inbox</code>.",
|
||||
"workspace_inbox": "podrazumevana lokacija prijemnog sandučeta za nove beleške kada se prebace na nekog pretka ove beleške iz radnog prostora",
|
||||
"sql_console_home": "podrazmevana lokacija beleški SQL konzole",
|
||||
"bookmark_folder": "beleška sa ovom oznakom će se pojaviti u obeleživačima kao fascikla (omogućavajući pristup njenim podređenim fasciklama)",
|
||||
"share_hidden_from_tree": "ova beleška je skrivena u levom navigacionom stablu, ali je i dalje dostupna preko svoje URL adrese",
|
||||
"share_external_link": "beleška će služiti kao veza ka eksternoj veb stranici u stablu deljenja",
|
||||
"share_alias": "definišite alias pomoću kog će beleška biti dostupna na https://your_trilium_host/share/[your_alias]",
|
||||
"share_omit_default_css": "CSS kod podrazumevane stranice za deljenje će biti izostavljen. Koristite ga kada pravite opsežne promene stila.",
|
||||
"share_root": "obeležava belešku koja se prikazuje na /share korenu.",
|
||||
"share_description": "definišite tekst koji će se dodati HTML meta oznaci za opis",
|
||||
"share_raw": "beleška će biti prikazana u svom sirovom (raw) formatu, bez HTML omotača",
|
||||
"share_disallow_robot_indexing": "zabraniće robotsko indeksiranje ove beleške putem zaglavlja <code>X-Robots-Tag: noindex</code>",
|
||||
"share_credentials": "potrebni su kredencijali za pristup ovoj deljenoj belešci. Očekuje se da vrednost bude u formatu „korisničko ime:lozinka“. Ne zaboravite da ovo označite kao nasledno da bi se primenilo na podbeleške/slike.",
|
||||
"share_index": "beleška sa ovom oznakom će izlistati sve korene deljenih beleški",
|
||||
"display_relations": "imena relacija razdvojenih zarezima koja treba da budu prikazana. Sva ostala će biti skrivena.",
|
||||
"hide_relations": "imena relacija razdvojenih zarezima koja treba da budu skrivena. Sva ostala će biti prikazana.",
|
||||
"title_template": "podrazumevani naslov beleški kreiranih kao deca ove beleške. Vrednost se procenjuje kao JavaScript string \n i stoga se može obogatiti dinamičkim sadržajem putem ubrizganih promenljivih <code>now</code> and <code>parentNote</code>. Primeri:\n \n <ul>\n <li><code>${parentNote.getLabelValue('authorName')}'s literary works</code></li>\n <li><code>Log for ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n </ul>\n \n Pogledati <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">wiki sa detaljima</a>, API dokumentacija za <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> i <a href=\"https://day.js.org/docs/en/display/format\">now</a> za detalje.",
|
||||
"template": "Ova beleška će biti prikazana u izboru dostupnih šablona prilikom pravljenja nove beleške",
|
||||
"toc": "<code>#toc</code> ili <code>#toc=show</code> će pristiliti Sadržaj (Table of Contents) da bude prikazan, <code>#toc=hide</code> prisiliti njegovo sakrivanje. Ako oznaka ne postoji, ponašanje će biti usklađeno sa globalnim podešavanjem",
|
||||
"color": "definiše boju beleške u stablu beleški, linkovima itd. Koristite bilo koju važeću CSS vrednost boje kao što je „crvena“ ili #a13d5f",
|
||||
"keyboard_shortcut": "Definiše prečicu na tastaturi koja će odmah preći na ovu belešku. Primer: „ctrl+alt+e“. Potrebno je ponovno učitavanje frontenda da bi promena stupila na snagu.",
|
||||
"keep_current_hoisting": "Otvaranje ove veze neće promeniti podizanje čak i ako beleška nije prikazana u trenutno podignutom podstablu.",
|
||||
"execute_button": "Naslov dugmeta koje će izvršiti trenutnu belešku sa kodom",
|
||||
"execute_description": "Duži opis trenutne beleške sa kodom prikazan je zajedno sa dugmetom za izvršavanje",
|
||||
"exclude_from_note_map": "Beleške sa ovom oznakom biće skrivene sa mape beleški",
|
||||
"new_notes_on_top": "Nove beleške će biti napravljene na vrhu matične beleške, a ne na dnu.",
|
||||
"hide_highlight_widget": "Sakrij vidžet sa listom istaknutih",
|
||||
"run_on_note_creation": "izvršava se kada se beleška napravi na serverskoj strani. Koristite ovu relaciju ako želite da pokrenete skriptu za sve beleške napravljene u okviru određenog podstabla. U tom slučaju, kreirajte je na korenu beleške podstabla i učinite je naslednom. Nova beleška napravljena unutar podstabla (bilo koje dubine) pokrenuće skriptu.",
|
||||
"run_on_child_note_creation": "izvršava se kada se napravi nova beleška ispod beleške gde je ova relacija definisana",
|
||||
"run_on_note_title_change": "izvršava se kada se promeni naslov beleške (uključuje i pravljenje beleške)",
|
||||
"run_on_note_content_change": "izvršava se kada se promeni sadržaj beleške (uključuje i pravljenje beleške).",
|
||||
"run_on_note_change": "izvršava se kada se promeni beleška (uključuje i pravljenje beleške). Ne uključuje promene sadržaja",
|
||||
"icon_class": "vrednost ove oznake se dodaje kao CSS klasa ikoni na stablu što može pomoći u vizuelnom razlikovanju beleški u stablu. Primer može biti bx bx-home - ikone su preuzete iz boxicons. Može se koristiti u šablonima beleški.",
|
||||
"page_size": "broj stavki po stranici u listi beleški",
|
||||
"custom_request_handler": "pogledajte <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Prilagođeni obrađivač zahteva</a>",
|
||||
"custom_resource_provider": "pogledajte <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Prilagođeni obrađivač zahteva</a>",
|
||||
"widget": "označava ovu belešku kao prilagođeni vidžet koji će biti dodat u stablo komponenti Trilijuma",
|
||||
"run_on_note_deletion": "izvršava se kada se beleška briše",
|
||||
"run_on_branch_creation": "izvršava se kada se pravi grana. Grana je veza između matične i podređene beleške i pravi se npr. prilikom kloniranja ili premeštanja beleške.",
|
||||
"run_on_branch_change": "izvršava se kada se grana ažurira.",
|
||||
"run_on_branch_deletion": "izvršava se kada se grana briše. Grana je veza između nadređene beleške i podređene beleške i briše se npr. prilikom premeštanja beleške (stara grana/veza se briše).",
|
||||
"run_on_attribute_creation": "izvršava se kada se pravi novi atribut za belešku koji definiše ovu relaciju",
|
||||
"run_on_attribute_change": " izvršava se kada se promeni atribut beleške koja definiše ovu relaciju. Ovo se pokreće i kada se atribut obriše",
|
||||
"relation_template": "atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete, sadržaj i podstablo beleške će biti dodati instanci beleške ako je prazna. Pogledajte dokumentaciju za detalje.",
|
||||
"inherit": "Atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete. Pogledajte relaciju šablona za sličan koncept. Pogledajte nasleđivanje atributa u dokumentaciji.",
|
||||
"render_note": "Beleške tipa „render HTML note“ će biti prikazane korišćenjem beleške za kod (HTML ili skripte) i potrebno je pomoću ove relacije ukazati na to koja beleška treba da se prikaže",
|
||||
"widget_relation": "meta ove relacije će biti izvršena i prikazana kao vidžet u bočnoj traci",
|
||||
"share_css": "CSS napomena koja će biti ubrizgana na stranicu za deljenje. CSS napomena mora biti i u deljenom podstablu. Razmotrite i korišćenje „share_hidden_from_tree“ i „share_omit_default_css“.",
|
||||
"share_js": "JavaScript beleška koja će biti ubrizgana na stranicu za deljenje. JS beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.",
|
||||
"share_template": "Ugrađena JavaScript beleška koja će se koristiti kao šablon za prikazivanje deljene beleške. U slučaju neuspeha vraća se na podrazumevani šablon. Razmislite o korišćenju „share_hidden_from_tree“.",
|
||||
"share_favicon": "Favicon beleška koju treba postaviti na deljenu stranicu. Obično je potrebno da je podesite da deli koren i učinite je naslednom. Favicon beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.",
|
||||
"is_owned_by_note": "je u vlasništvu beleške",
|
||||
"other_notes_with_name": "Ostale beleške sa {{attributeType}} nazivom „{{attributeName}}“",
|
||||
"and_more": "... i još {{count}}.",
|
||||
"print_landscape": "Prilikom izvoza u PDF, menja orijentaciju stranice u pejzažnu umesto uspravne.",
|
||||
"print_page_size": "Prilikom izvoza u PDF, menja veličinu stranice. Podržane vrednosti: <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": "Boja"
|
||||
},
|
||||
"ai_llm": {
|
||||
"n_notes_queued_0": "{{ count }} beleška stavljena u red za indeksiranje",
|
||||
"n_notes_queued_1": "{{ count }} beleški stavljeno u red za indeksiranje",
|
||||
"n_notes_queued_2": "{{ count }} beleški stavljeno u red za indeksiranje",
|
||||
"notes_indexed_0": "{{ count }} beleška je indeksirana",
|
||||
"notes_indexed_1": "{{ count }} beleški je indeksirano",
|
||||
"notes_indexed_2": "{{ count }} beleški je indeksirano"
|
||||
},
|
||||
"attribute_editor": {
|
||||
"help_text_body1": "Da biste dodali oznaku, samo unesite npr. <code>#rock</code> ili ako želite da dodate i vrednost, onda npr. <code>#year = 2020</code>",
|
||||
"help_text_body2": "Za relaciju, unesite <code>~author = @</code> što bi trebalo da otvori automatsko dovršavanje gde možete potražiti željenu belešku.",
|
||||
"help_text_body3": "Alternativno, možete dodati oznaku i relaciju pomoću dugmeta <code>+</code> sa desne strane.",
|
||||
"save_attributes": "Sačuvaj atribute <enter>",
|
||||
"add_a_new_attribute": "Dodajte novi atribut",
|
||||
"add_new_label": "Dodajte novu oznaku <kbd data-command=\"addNewLabel\"></kbd>",
|
||||
"add_new_relation": "Dodajte novu relaciju <kbd data-command=\"addNewRelation\"></kbd>",
|
||||
"add_new_label_definition": "Dodajte novu definiciju oznake",
|
||||
"add_new_relation_definition": "Dodajte novu definiciju relacije",
|
||||
"placeholder": "Ovde unesite oznake i relacije"
|
||||
},
|
||||
"abstract_bulk_action": {
|
||||
"remove_this_search_action": "Ukloni ovu radnju pretrage"
|
||||
},
|
||||
"execute_script": {
|
||||
"execute_script": "Izvrši skriptu",
|
||||
"help_text": "Možete izvršiti jednostavne skripte na podudarnim beleškama.",
|
||||
"example_1": "Na primer, da biste dodali string u naslov beleške, koristite ovu malu skriptu:",
|
||||
"example_2": "Složeniji primer bi bio brisanje svih atributa podudarnih beleški:"
|
||||
},
|
||||
"add_label": {
|
||||
"add_label": "Dodaj oznaku",
|
||||
"label_name_placeholder": "ime oznake",
|
||||
"label_name_title": "Alfanumerički znakovi, donja crta i dvotačka su dozvoljeni znakovi.",
|
||||
"to_value": "za vrednost",
|
||||
"new_value_placeholder": "nova vrednost",
|
||||
"help_text": "Na svim podudarnim beleškama:",
|
||||
"help_text_item1": "dodajte datu oznaku ako beleška još uvek nema jednu",
|
||||
"help_text_item2": "ili izmenite vrednost postojeće oznake",
|
||||
"help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti."
|
||||
},
|
||||
"delete_label": {
|
||||
"delete_label": "Obriši oznaku",
|
||||
"label_name_placeholder": "ime oznake",
|
||||
"label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi."
|
||||
},
|
||||
"rename_label": {
|
||||
"rename_label": "Preimenuj oznaku",
|
||||
"rename_label_from": "Preimenuj oznaku iz",
|
||||
"old_name_placeholder": "stari naziv",
|
||||
"to": "U",
|
||||
"new_name_placeholder": "novi naziv",
|
||||
"name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi."
|
||||
},
|
||||
"update_label_value": {
|
||||
"update_label_value": "Ažuriraj vrednost oznake",
|
||||
"label_name_placeholder": "ime oznake",
|
||||
"label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi.",
|
||||
"to_value": "u vrednost",
|
||||
"new_value_placeholder": "nova vrednost",
|
||||
"help_text": "Na svim podudarnim beleškama, promenite vrednost postojeće oznake.",
|
||||
"help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti."
|
||||
},
|
||||
"delete_note": {
|
||||
"delete_note": "Obriši belešku",
|
||||
"delete_matched_notes": "Obriši podudarne beleške",
|
||||
"delete_matched_notes_description": "Ovo će obrisati podudarne beleške.",
|
||||
"undelete_notes_instruction": "Nakon brisanja, moguće ga je poništiti iz dijaloga Nedavne izmene."
|
||||
}
|
||||
}
|
||||
71
apps/client/src/translations/tr/translation.json
Normal file
71
apps/client/src/translations/tr/translation.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"about": {
|
||||
"close": "Kapat",
|
||||
"homepage": "Giriş sayfası:",
|
||||
"app_version": "Uygulama versiyonu:",
|
||||
"db_version": "Veritabanı versiyonu:"
|
||||
},
|
||||
"add_link": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"branch_prefix": {
|
||||
"close": "Kapat",
|
||||
"save": "Kaydet"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"clone_to": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"confirm": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"recent_changes": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"delete_notes": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"export": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"help": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"include_note": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"import": {
|
||||
"close": "Kapat",
|
||||
"chooseImportFile": "İçe aktarım dosyası",
|
||||
"importDescription": "Seçilen dosya(lar) alt not olarak içe aktarılacaktır"
|
||||
},
|
||||
"info": {
|
||||
"closeButton": "Kapat"
|
||||
},
|
||||
"jump_to_note": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"markdown_import": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"move_to": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"password_not_set": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"prompt": {
|
||||
"close": "Kapat"
|
||||
},
|
||||
"protected_session_password": {
|
||||
"close_label": "Kapat"
|
||||
},
|
||||
"revisions": {
|
||||
"close": "Kapat"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
5
apps/client/src/types-assets.d.ts
vendored
5
apps/client/src/types-assets.d.ts
vendored
@@ -3,6 +3,11 @@ declare module "*.png" {
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module "*.json" {
|
||||
var content: any;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*?url" {
|
||||
var path: string;
|
||||
export default path;
|
||||
|
||||
10
apps/client/src/types.d.ts
vendored
10
apps/client/src/types.d.ts
vendored
@@ -97,16 +97,6 @@ declare global {
|
||||
setNote(noteId: string);
|
||||
}
|
||||
|
||||
interface JQueryStatic {
|
||||
hotkeys: {
|
||||
options: {
|
||||
filterInputAcceptingElements: boolean;
|
||||
filterContentEditable: boolean;
|
||||
filterTextInputs: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var logError: (message: string, e?: Error | string) => void;
|
||||
var logInfo: (message: string) => void;
|
||||
var glob: CustomGlobals;
|
||||
|
||||
@@ -142,6 +142,7 @@ const TPL = /*html*/`
|
||||
<option value="datetime">${t("attribute_detail.date_time")}</option>
|
||||
<option value="time">${t("attribute_detail.time")}</option>
|
||||
<option value="url">${t("attribute_detail.url")}</option>
|
||||
<option value="color">${t("attribute_detail.color_type")}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||
import type { CommandNames } from "../../components/app_context.js";
|
||||
import keyboardActionsService, { type Action } from "../../services/keyboard_actions.js";
|
||||
import keyboardActionsService from "../../services/keyboard_actions.js";
|
||||
import AbstractButtonWidget, { type AbstractButtonWidgetSettings } from "./abstract_button.js";
|
||||
import type { ButtonNoteIdProvider } from "./button_from_note.js";
|
||||
|
||||
let actions: Action[];
|
||||
let actions: ActionKeyboardShortcut[];
|
||||
|
||||
keyboardActionsService.getActions().then((as) => (actions = as));
|
||||
|
||||
@@ -49,7 +50,7 @@ export default class CommandButtonWidget extends AbstractButtonWidget<CommandBut
|
||||
|
||||
const action = actions.find((act) => act.actionName === this._command);
|
||||
|
||||
if (action && action.effectiveShortcuts.length > 0) {
|
||||
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
|
||||
return `${title} (${action.effectiveShortcuts.join(", ")})`;
|
||||
} else {
|
||||
return title;
|
||||
|
||||
@@ -186,7 +186,7 @@ export default class NoteActionsWidget extends NoteContextAwareWidget {
|
||||
|
||||
this.$convertNoteIntoAttachmentButton.toggle(note.isEligibleForConversionToAttachment());
|
||||
|
||||
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap"].includes(note.type));
|
||||
this.toggleDisabled(this.$findInTextButton, ["text", "code", "book", "mindMap", "doc"].includes(note.type));
|
||||
|
||||
this.toggleDisabled(this.$showAttachmentsButton, !isInOptions);
|
||||
this.toggleDisabled(this.$showSourceButton, ["text", "code", "relationMap", "mermaid", "canvas", "mindMap"].includes(note.type));
|
||||
|
||||
@@ -268,7 +268,7 @@ export default class RibbonContainer extends NoteContextAwareWidget {
|
||||
const action = actions.find((act) => act.actionName === toggleCommandName);
|
||||
const title = $(this).attr("data-title");
|
||||
|
||||
if (action && action.effectiveShortcuts.length > 0) {
|
||||
if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) {
|
||||
return `${title} (${action.effectiveShortcuts.join(", ")})`;
|
||||
} else {
|
||||
return title ?? "";
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import openService from "../../services/open.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
|
||||
interface AppInfo {
|
||||
appVersion: string;
|
||||
dbVersion: number;
|
||||
syncVersion: number;
|
||||
buildDate: string;
|
||||
buildRevision: string;
|
||||
dataDirectory: string;
|
||||
}
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="about-dialog modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("about.title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("about.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th>${t("about.homepage")}</th>
|
||||
<td><a class="tn-link" href="https://github.com/TriliumNext/Trilium" class="external">https://github.com/TriliumNext/Trilium</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.app_version")}</th>
|
||||
<td class="app-version"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.db_version")}</th>
|
||||
<td class="db-version"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.sync_version")}</th>
|
||||
<td class="sync-version"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.build_date")}</th>
|
||||
<td class="build-date"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.build_revision")}</th>
|
||||
<td><a class="tn-link build-revision external" href="" target="_blank"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${t("about.data_directory")}</th>
|
||||
<td class="data-directory"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.about-dialog a {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
export default class AboutDialog extends BasicWidget {
|
||||
private $appVersion!: JQuery<HTMLElement>;
|
||||
private $dbVersion!: JQuery<HTMLElement>;
|
||||
private $syncVersion!: JQuery<HTMLElement>;
|
||||
private $buildDate!: JQuery<HTMLElement>;
|
||||
private $buildRevision!: JQuery<HTMLElement>;
|
||||
private $dataDirectory!: JQuery<HTMLElement>;
|
||||
|
||||
doRender(): void {
|
||||
this.$widget = $(TPL);
|
||||
this.$appVersion = this.$widget.find(".app-version");
|
||||
this.$dbVersion = this.$widget.find(".db-version");
|
||||
this.$syncVersion = this.$widget.find(".sync-version");
|
||||
this.$buildDate = this.$widget.find(".build-date");
|
||||
this.$buildRevision = this.$widget.find(".build-revision");
|
||||
this.$dataDirectory = this.$widget.find(".data-directory");
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const appInfo = await server.get<AppInfo>("app-info");
|
||||
|
||||
this.$appVersion.text(appInfo.appVersion);
|
||||
this.$dbVersion.text(appInfo.dbVersion.toString());
|
||||
this.$syncVersion.text(appInfo.syncVersion.toString());
|
||||
this.$buildDate.text(formatDateTime(appInfo.buildDate));
|
||||
this.$buildRevision.text(appInfo.buildRevision);
|
||||
this.$buildRevision.attr("href", `https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`);
|
||||
if (utils.isElectron()) {
|
||||
this.$dataDirectory.html(
|
||||
$("<a></a>", {
|
||||
href: "#",
|
||||
class: "tn-link",
|
||||
text: appInfo.dataDirectory
|
||||
}).prop("outerHTML")
|
||||
);
|
||||
this.$dataDirectory.find("a").on("click", (event: JQuery.ClickEvent) => {
|
||||
event.preventDefault();
|
||||
openService.openDirectory(appInfo.dataDirectory);
|
||||
});
|
||||
} else {
|
||||
this.$dataDirectory.text(appInfo.dataDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
async openAboutDialogEvent() {
|
||||
await this.refresh();
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
||||
91
apps/client/src/widgets/dialogs/about.tsx
Normal file
91
apps/client/src/widgets/dialogs/about.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import ReactBasicWidget from "../react/ReactBasicWidget.js";
|
||||
import Modal from "../react/Modal.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { formatDateTime } from "../../utils/formatters.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import openService from "../../services/open.js";
|
||||
import { useState } from "preact/hooks";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import type { AppInfo } from "@triliumnext/commons";
|
||||
|
||||
function AboutDialogComponent() {
|
||||
let [appInfo, setAppInfo] = useState<AppInfo | null>(null);
|
||||
|
||||
async function onShown() {
|
||||
const appInfo = await server.get<AppInfo>("app-info");
|
||||
setAppInfo(appInfo);
|
||||
}
|
||||
|
||||
const forceWordBreak: CSSProperties = { wordBreak: "break-all" };
|
||||
|
||||
return (
|
||||
<Modal className="about-dialog" size="lg" title={t("about.title")} onShown={onShown}>
|
||||
{(appInfo !== null) ? (
|
||||
<table className="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{t("about.homepage")}</th>
|
||||
<td><a className="tn-link external" href="https://github.com/TriliumNext/Trilium" style={forceWordBreak}>https://github.com/TriliumNext/Trilium</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.app_version")}</th>
|
||||
<td className="app-version">{appInfo.appVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.db_version")}</th>
|
||||
<td className="db-version">{appInfo.dbVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.sync_version")}</th>
|
||||
<td className="sync-version">{appInfo.syncVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.build_date")}</th>
|
||||
<td className="build-date">{formatDateTime(appInfo.buildDate)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.build_revision")}</th>
|
||||
<td>
|
||||
<a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("about.data_directory")}</th>
|
||||
<td className="data-directory">
|
||||
<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="loading-spinner"></div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectoryLink({ directory, style }: { directory: string, style?: CSSProperties }) {
|
||||
if (utils.isElectron()) {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
openService.openDirectory(directory);
|
||||
};
|
||||
|
||||
return <a className="tn-link" href="#" onClick={onClick} style={style}></a>
|
||||
} else {
|
||||
return <span style={style}>{directory}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export default class AboutDialog extends ReactBasicWidget {
|
||||
|
||||
get component() {
|
||||
return <AboutDialogComponent />;
|
||||
}
|
||||
|
||||
async openAboutDialogEvent() {
|
||||
openDialog(this.$widget);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import BasicWidget from "../basic_widget.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import { openDialog } from "../../services/dialog.js";
|
||||
import commandRegistry from "../../services/command_registry.js";
|
||||
|
||||
const TPL = /*html*/`<div class="jump-to-note-dialog modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
@@ -34,7 +35,8 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
private modal!: bootstrap.Modal;
|
||||
private $autoComplete!: JQuery<HTMLElement>;
|
||||
private $results!: JQuery<HTMLElement>;
|
||||
private $showInFullTextButton!: JQuery<HTMLElement>;
|
||||
private $modalFooter!: JQuery<HTMLElement>;
|
||||
private isCommandMode: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -48,13 +50,44 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
|
||||
this.$autoComplete = this.$widget.find(".jump-to-note-autocomplete");
|
||||
this.$results = this.$widget.find(".jump-to-note-results");
|
||||
this.$showInFullTextButton = this.$widget.find(".show-in-full-text-button");
|
||||
this.$showInFullTextButton.on("click", (e) => this.showInFullText(e));
|
||||
this.$modalFooter = this.$widget.find(".modal-footer");
|
||||
this.$modalFooter.find(".show-in-full-text-button").on("click", (e) => this.showInFullText(e));
|
||||
|
||||
shortcutService.bindElShortcut(this.$widget, "ctrl+return", (e) => this.showInFullText(e));
|
||||
|
||||
// Monitor input changes to detect command mode switches
|
||||
this.$autoComplete.on("input", () => {
|
||||
this.updateCommandModeState();
|
||||
});
|
||||
}
|
||||
|
||||
private updateCommandModeState() {
|
||||
const currentValue = String(this.$autoComplete.val() || "");
|
||||
const newCommandMode = currentValue.startsWith(">");
|
||||
|
||||
if (newCommandMode !== this.isCommandMode) {
|
||||
this.isCommandMode = newCommandMode;
|
||||
this.updateButtonVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
private updateButtonVisibility() {
|
||||
if (this.isCommandMode) {
|
||||
this.$modalFooter.hide();
|
||||
} else {
|
||||
this.$modalFooter.show();
|
||||
}
|
||||
}
|
||||
|
||||
async jumpToNoteEvent() {
|
||||
await this.openDialog();
|
||||
}
|
||||
|
||||
async commandPaletteEvent() {
|
||||
await this.openDialog(true);
|
||||
}
|
||||
|
||||
private async openDialog(commandMode = false) {
|
||||
const dialogPromise = openDialog(this.$widget);
|
||||
if (utils.isMobile()) {
|
||||
dialogPromise.then(($dialog) => {
|
||||
@@ -81,50 +114,89 @@ export default class JumpToNoteDialog extends BasicWidget {
|
||||
}
|
||||
|
||||
// first open dialog, then refresh since refresh is doing focus which should be visible
|
||||
this.refresh();
|
||||
this.refresh(commandMode);
|
||||
|
||||
this.lastOpenedTs = Date.now();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
async refresh(commandMode = false) {
|
||||
noteAutocompleteService
|
||||
.initNoteAutocomplete(this.$autoComplete, {
|
||||
allowCreatingNotes: true,
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowJumpToSearchNotes: true,
|
||||
container: this.$results[0]
|
||||
container: this.$results[0],
|
||||
isCommandPalette: true
|
||||
})
|
||||
// clear any event listener added in previous invocation of this function
|
||||
.off("autocomplete:noteselected")
|
||||
.off("autocomplete:commandselected")
|
||||
.on("autocomplete:noteselected", function (event, suggestion, dataset) {
|
||||
if (!suggestion.notePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
|
||||
})
|
||||
.on("autocomplete:commandselected", async (event, suggestion, dataset) => {
|
||||
if (!suggestion.commandId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.modal.hide();
|
||||
await commandRegistry.executeCommand(suggestion.commandId);
|
||||
});
|
||||
|
||||
// if you open the Jump To dialog soon after using it previously, it can often mean that you
|
||||
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
|
||||
// so we'll keep the content.
|
||||
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
|
||||
if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) {
|
||||
noteAutocompleteService.showRecentNotes(this.$autoComplete);
|
||||
if (commandMode) {
|
||||
// Start in command mode - manually trigger command search
|
||||
this.$autoComplete.autocomplete("val", ">");
|
||||
this.isCommandMode = true;
|
||||
this.updateButtonVisibility();
|
||||
|
||||
// Manually populate with all commands immediately
|
||||
noteAutocompleteService.showAllCommands(this.$autoComplete);
|
||||
|
||||
this.$autoComplete.trigger("focus");
|
||||
} else {
|
||||
this.$autoComplete
|
||||
// hack, the actual search value is stored in <pre> element next to the search input
|
||||
// this is important because the search input value is replaced with the suggestion note's title
|
||||
.autocomplete("val", this.$autoComplete.next().text())
|
||||
.trigger("focus")
|
||||
.trigger("select");
|
||||
// if you open the Jump To dialog soon after using it previously, it can often mean that you
|
||||
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
|
||||
// so we'll keep the content.
|
||||
// if it's outside of this time limit, then we assume it's a completely new search and show recent notes instead.
|
||||
if (Date.now() - this.lastOpenedTs > KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000) {
|
||||
this.isCommandMode = false;
|
||||
this.updateButtonVisibility();
|
||||
noteAutocompleteService.showRecentNotes(this.$autoComplete);
|
||||
} else {
|
||||
this.$autoComplete
|
||||
// hack, the actual search value is stored in <pre> element next to the search input
|
||||
// this is important because the search input value is replaced with the suggestion note's title
|
||||
.autocomplete("val", this.$autoComplete.next().text())
|
||||
.trigger("focus")
|
||||
.trigger("select");
|
||||
|
||||
// Update command mode state based on the restored value
|
||||
this.updateCommandModeState();
|
||||
|
||||
// If we restored a command mode value, manually trigger command display
|
||||
if (this.isCommandMode) {
|
||||
// Clear the value first, then set it to ">" to trigger a proper change
|
||||
this.$autoComplete.autocomplete("val", "");
|
||||
noteAutocompleteService.showAllCommands(this.$autoComplete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showInFullText(e: JQuery.TriggeredEvent) {
|
||||
showInFullText(e: JQuery.TriggeredEvent | KeyboardEvent) {
|
||||
// stop from propagating upwards (dangerous, especially with ctrl+enter executable javascript notes)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Don't perform full text search in command mode
|
||||
if (this.isCommandMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchString = String(this.$autoComplete.val());
|
||||
|
||||
this.triggerCommand("searchNotes", { searchString });
|
||||
|
||||
@@ -106,7 +106,11 @@ export default class PopupEditorDialog extends Container<BasicWidget> {
|
||||
focus: false
|
||||
});
|
||||
|
||||
await this.noteContext.setNote(noteIdOrPath);
|
||||
await this.noteContext.setNote(noteIdOrPath, {
|
||||
viewScope: {
|
||||
readOnlyTemporarilyDisabled: true
|
||||
}
|
||||
});
|
||||
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && "blur" in activeEl) {
|
||||
|
||||
@@ -88,7 +88,9 @@ export default class SortChildNotesDialog extends BasicWidget {
|
||||
this.$widget = $(TPL);
|
||||
this.$form = this.$widget.find(".sort-child-notes-form");
|
||||
|
||||
this.$form.on("submit", async () => {
|
||||
this.$form.on("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const sortBy = this.$form.find("input[name='sort-by']:checked").val();
|
||||
const sortDirection = this.$form.find("input[name='sort-direction']:checked").val();
|
||||
const foldersFirst = this.$form.find("input[name='sort-folders-first']").is(":checked");
|
||||
|
||||
@@ -97,6 +97,7 @@ const TPL = /*html*/`
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const SUPPORTED_NOTE_TYPES = ["text", "code", "render", "mindMap", "doc"];
|
||||
export default class FindWidget extends NoteContextAwareWidget {
|
||||
|
||||
private searchTerm: string | null;
|
||||
@@ -188,7 +189,7 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["text", "code", "render", "mindMap"].includes(this.note?.type ?? "")) {
|
||||
if (!SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,6 +252,7 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
const readOnly = await this.noteContext?.isReadOnly();
|
||||
return readOnly ? this.htmlHandler : this.textHandler;
|
||||
case "mindMap":
|
||||
case "doc":
|
||||
return this.htmlHandler;
|
||||
default:
|
||||
console.warn("FindWidget: Unsupported note type for find widget", this.note?.type);
|
||||
@@ -354,7 +356,7 @@ export default class FindWidget extends NoteContextAwareWidget {
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && ["text", "code", "render", "mindMap"].includes(this.note?.type ?? "");
|
||||
return super.isEnabled() && SUPPORTED_NOTE_TYPES.includes(this.note?.type ?? "");
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
|
||||
@@ -35,7 +35,8 @@ export const byBookType: Record<ViewTypeOptions, string | null> = {
|
||||
grid: "8QqnMzx393bx",
|
||||
calendar: "xWbu3jpNWapp",
|
||||
table: "2FvYrpmOXm29",
|
||||
geoMap: "81SGnPGMk7Xc"
|
||||
geoMap: "81SGnPGMk7Xc",
|
||||
board: "CtBQqbwXDx1w"
|
||||
};
|
||||
|
||||
export default class ContextualHelpButton extends NoteContextAwareWidget {
|
||||
|
||||
@@ -48,6 +48,9 @@ export async function checkSessionExists(noteId: string): Promise<boolean> {
|
||||
* @param onContentUpdate - Callback for content updates
|
||||
* @param onThinkingUpdate - Callback for thinking updates
|
||||
* @param onToolExecution - Callback for tool execution
|
||||
* @param onProgressUpdate - Callback for progress updates
|
||||
* @param onUserInteraction - Callback for user interaction requests
|
||||
* @param onErrorRecovery - Callback for error recovery options
|
||||
* @param onComplete - Callback for completion
|
||||
* @param onError - Callback for errors
|
||||
*/
|
||||
@@ -57,6 +60,9 @@ export async function setupStreamingResponse(
|
||||
onContentUpdate: (content: string, isDone?: boolean) => void,
|
||||
onThinkingUpdate: (thinking: string) => void,
|
||||
onToolExecution: (toolData: any) => void,
|
||||
onProgressUpdate: (progressData: any) => void,
|
||||
onUserInteraction: (interactionData: any) => Promise<any>,
|
||||
onErrorRecovery: (errorData: any) => Promise<any>,
|
||||
onComplete: () => void,
|
||||
onError: (error: Error) => void
|
||||
): Promise<void> {
|
||||
@@ -66,9 +72,14 @@ export async function setupStreamingResponse(
|
||||
let timeoutId: number | null = null;
|
||||
let initialTimeoutId: number | null = null;
|
||||
let cleanupTimeoutId: number | null = null;
|
||||
let heartbeatTimeoutId: number | null = null;
|
||||
let receivedAnyMessage = false;
|
||||
let eventListener: ((event: Event) => void) | null = null;
|
||||
let lastMessageTimestamp = 0;
|
||||
|
||||
// Configuration for timeouts
|
||||
const HEARTBEAT_TIMEOUT_MS = 30000; // 30 seconds between messages
|
||||
const MAX_IDLE_TIME_MS = 60000; // 60 seconds max idle time
|
||||
|
||||
// Create a unique identifier for this response process
|
||||
const responseId = `llm-stream-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||
@@ -101,12 +112,43 @@ export async function setupStreamingResponse(
|
||||
}
|
||||
})();
|
||||
|
||||
// Function to reset heartbeat timeout
|
||||
const resetHeartbeatTimeout = () => {
|
||||
if (heartbeatTimeoutId) {
|
||||
window.clearTimeout(heartbeatTimeoutId);
|
||||
}
|
||||
|
||||
heartbeatTimeoutId = window.setTimeout(() => {
|
||||
const idleTime = Date.now() - lastMessageTimestamp;
|
||||
console.warn(`[${responseId}] No message received for ${idleTime}ms`);
|
||||
|
||||
if (idleTime > MAX_IDLE_TIME_MS) {
|
||||
console.error(`[${responseId}] Connection appears to be stalled (idle for ${idleTime}ms)`);
|
||||
performCleanup();
|
||||
reject(new Error('Connection lost: The AI service stopped responding. Please try again.'));
|
||||
} else {
|
||||
// Send a warning but continue waiting
|
||||
console.warn(`[${responseId}] Connection may be slow, continuing to wait...`);
|
||||
resetHeartbeatTimeout(); // Reset for another check
|
||||
}
|
||||
}, HEARTBEAT_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
// Function to safely perform cleanup
|
||||
const performCleanup = () => {
|
||||
// Clear all timeouts
|
||||
if (cleanupTimeoutId) {
|
||||
window.clearTimeout(cleanupTimeoutId);
|
||||
cleanupTimeoutId = null;
|
||||
}
|
||||
if (heartbeatTimeoutId) {
|
||||
window.clearTimeout(heartbeatTimeoutId);
|
||||
heartbeatTimeoutId = null;
|
||||
}
|
||||
if (initialTimeoutId) {
|
||||
window.clearTimeout(initialTimeoutId);
|
||||
initialTimeoutId = null;
|
||||
}
|
||||
|
||||
console.log(`[${responseId}] Performing final cleanup of event listener`);
|
||||
cleanupEventListener(eventListener);
|
||||
@@ -115,13 +157,15 @@ export async function setupStreamingResponse(
|
||||
};
|
||||
|
||||
// Set initial timeout to catch cases where no message is received at all
|
||||
// Increased timeout and better error messaging
|
||||
const INITIAL_TIMEOUT_MS = 15000; // 15 seconds for initial response
|
||||
initialTimeoutId = window.setTimeout(() => {
|
||||
if (!receivedAnyMessage) {
|
||||
console.error(`[${responseId}] No initial message received within timeout`);
|
||||
console.error(`[${responseId}] No initial message received within ${INITIAL_TIMEOUT_MS}ms timeout`);
|
||||
performCleanup();
|
||||
reject(new Error('No response received from server'));
|
||||
reject(new Error('Connection timeout: The AI service is taking longer than expected to respond. Please check your connection and try again.'));
|
||||
}
|
||||
}, 10000);
|
||||
}, INITIAL_TIMEOUT_MS);
|
||||
|
||||
// Create a message handler for CustomEvents
|
||||
eventListener = (event: Event) => {
|
||||
@@ -155,6 +199,12 @@ export async function setupStreamingResponse(
|
||||
window.clearTimeout(initialTimeoutId);
|
||||
initialTimeoutId = null;
|
||||
}
|
||||
|
||||
// Start heartbeat monitoring
|
||||
resetHeartbeatTimeout();
|
||||
} else {
|
||||
// Reset heartbeat on each new message
|
||||
resetHeartbeatTimeout();
|
||||
}
|
||||
|
||||
// Handle error
|
||||
@@ -177,6 +227,28 @@ export async function setupStreamingResponse(
|
||||
onToolExecution(message.toolExecution);
|
||||
}
|
||||
|
||||
// Handle progress updates
|
||||
if (message.progressUpdate) {
|
||||
console.log(`[${responseId}] Progress update:`, message.progressUpdate);
|
||||
onProgressUpdate(message.progressUpdate);
|
||||
}
|
||||
|
||||
// Handle user interaction requests
|
||||
if (message.userInteraction) {
|
||||
console.log(`[${responseId}] User interaction request:`, message.userInteraction);
|
||||
onUserInteraction(message.userInteraction).catch(error => {
|
||||
console.error(`[${responseId}] Error handling user interaction:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle error recovery options
|
||||
if (message.errorRecovery) {
|
||||
console.log(`[${responseId}] Error recovery options:`, message.errorRecovery);
|
||||
onErrorRecovery(message.errorRecovery).catch(error => {
|
||||
console.error(`[${responseId}] Error handling error recovery:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle content updates
|
||||
if (message.content) {
|
||||
// Simply append the new content - no complex deduplication
|
||||
@@ -258,3 +330,54 @@ export async function getDirectResponse(noteId: string, messageParams: any): Pro
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send user interaction response
|
||||
* @param interactionId - The interaction ID
|
||||
* @param response - The user's response
|
||||
*/
|
||||
export async function sendUserInteractionResponse(interactionId: string, response: string): Promise<void> {
|
||||
try {
|
||||
await server.post<any>(`llm/interactions/${interactionId}/respond`, {
|
||||
response: response
|
||||
});
|
||||
console.log(`User interaction response sent: ${interactionId} -> ${response}`);
|
||||
} catch (error) {
|
||||
console.error('Error sending user interaction response:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error recovery choice
|
||||
* @param sessionId - The chat session ID
|
||||
* @param errorId - The error ID
|
||||
* @param action - The recovery action chosen
|
||||
* @param parameters - Optional parameters for the action
|
||||
*/
|
||||
export async function sendErrorRecoveryChoice(sessionId: string, errorId: string, action: string, parameters?: any): Promise<void> {
|
||||
try {
|
||||
await server.post<any>(`llm/chat/${sessionId}/error/${errorId}/recover`, {
|
||||
action: action,
|
||||
parameters: parameters
|
||||
});
|
||||
console.log(`Error recovery choice sent: ${errorId} -> ${action}`);
|
||||
} catch (error) {
|
||||
console.error('Error sending error recovery choice:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing operations
|
||||
* @param sessionId - The chat session ID
|
||||
*/
|
||||
export async function cancelChatOperations(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await server.post<any>(`llm/chat/${sessionId}/cancel`, {});
|
||||
console.log(`Chat operations cancelled for session: ${sessionId}`);
|
||||
} catch (error) {
|
||||
console.error('Error cancelling chat operations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
968
apps/client/src/widgets/llm_chat/enhanced_components.css
Normal file
968
apps/client/src/widgets/llm_chat/enhanced_components.css
Normal file
@@ -0,0 +1,968 @@
|
||||
/* Enhanced LLM Chat Components CSS */
|
||||
|
||||
/* =======================
|
||||
PROGRESS INDICATOR STYLES
|
||||
======================= */
|
||||
|
||||
.llm-progress-container {
|
||||
background: var(--main-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-progress-container.fade-in {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.llm-progress-container.fade-out {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.llm-progress-header {
|
||||
padding: 15px 20px 10px;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.llm-progress-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--main-text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.llm-progress-overall {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.llm-progress-bar-container {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.llm-progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-color), var(--accent-color-darker));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-progress-percentage {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-text-color);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.llm-progress-stages {
|
||||
padding: 15px 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.llm-progress-stage {
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-progress-stage:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stage-status-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stage-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.stage-timing {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stage-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stage-progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-color);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.stage-progress-text {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
min-width: 35px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stage-message {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
margin-left: 30px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Stage status styles */
|
||||
.stage-pending .stage-progress-fill {
|
||||
background: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.stage-running .stage-progress-fill {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
.stage-completed .stage-progress-fill {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.stage-failed .stage-progress-fill {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.llm-progress-footer {
|
||||
padding: 10px 20px 15px;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.llm-progress-time-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.llm-progress-cancel-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.llm-progress-cancel-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.llm-progress-cancel-btn:disabled {
|
||||
background: var(--muted-text-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
USER INTERACTION STYLES
|
||||
======================= */
|
||||
|
||||
.llm-interaction-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-interaction-overlay.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.llm-interaction-modal-container {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.llm-interaction-modal {
|
||||
background: var(--main-background-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.llm-interaction-modal.show {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 20px 15px;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header.risk-high {
|
||||
background: linear-gradient(135deg, #dc3545, #c82333);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-header.risk-medium {
|
||||
background: linear-gradient(135deg, #ffc107, #e0a800);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.modal-header.risk-low {
|
||||
background: linear-gradient(135deg, #28a745, #1e7e34);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.risk-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.risk-label {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
font-size: 14px;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tool-arguments {
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.arguments-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.arguments-content {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.argument-item {
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.argument-key {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.argument-value {
|
||||
color: var(--main-text-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.no-arguments {
|
||||
color: var(--muted-text-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.confirmation-message,
|
||||
.choice-message,
|
||||
.input-message {
|
||||
font-size: 14px;
|
||||
color: var(--main-text-color);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.choice-options {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.choice-option {
|
||||
background: var(--accented-background-color);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.choice-option:hover {
|
||||
border-color: var(--accent-color);
|
||||
background: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 600;
|
||||
color: var(--main-text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.option-description {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.input-field input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--main-background-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.input-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.2);
|
||||
}
|
||||
|
||||
.timeout-indicator {
|
||||
background: var(--accented-background-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeout-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeout-countdown {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.countdown-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--main-border-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.countdown-fill {
|
||||
height: 100%;
|
||||
background: #ffc107;
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.countdown-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 15px 20px 20px;
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-color-darker);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--muted-text-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--main-text-color);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
/* =======================
|
||||
ERROR RECOVERY STYLES
|
||||
======================= */
|
||||
|
||||
.llm-error-recovery-container {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.llm-error-recovery-item {
|
||||
background: var(--main-background-color);
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.llm-error-recovery-item.fade-out {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.error-header {
|
||||
background: linear-gradient(135deg, #dc3545, #c82333);
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-tool-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.error-attempt-info {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.error-type-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-message-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.error-message-content {
|
||||
background: var(--accented-background-color);
|
||||
border-left: 4px solid #dc3545;
|
||||
padding: 10px 12px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 14px;
|
||||
color: var(--main-text-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error-context {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.context-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.context-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-text-color);
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.context-content {
|
||||
background: var(--accented-background-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.param-key {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: var(--main-text-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.previous-attempts-list,
|
||||
.suggestions-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.previous-attempts-list li,
|
||||
.suggestions-list li {
|
||||
margin-bottom: 4px;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.auto-retry-section {
|
||||
background: linear-gradient(135deg, #ffc107, #e0a800);
|
||||
color: #212529;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.auto-retry-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.retry-countdown {
|
||||
font-weight: 700;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.auto-retry-progress {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.retry-progress-bar {
|
||||
height: 6px;
|
||||
background: rgba(33, 37, 41, 0.2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.retry-progress-fill {
|
||||
height: 100%;
|
||||
background: #dc3545;
|
||||
border-radius: 3px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.cancel-auto-retry {
|
||||
background: rgba(33, 37, 41, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-auto-retry:hover {
|
||||
background: #212529;
|
||||
}
|
||||
|
||||
.recovery-actions {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.recovery-actions-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--main-text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.recovery-actions-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recovery-action {
|
||||
background: var(--accented-background-color);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recovery-action:hover {
|
||||
border-color: var(--accent-color);
|
||||
background: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.action-retry:hover {
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
.action-skip:hover {
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.action-modify:hover {
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.action-abort:hover {
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.action-alternative:hover {
|
||||
border-color: #17a2b8;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-retry .action-icon {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.action-skip .action-icon {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.action-modify .action-icon {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.action-abort .action-icon {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.action-alternative .action-icon {
|
||||
background: #17a2b8;
|
||||
}
|
||||
|
||||
.action-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--main-text-color);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.action-description {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.action-arrow {
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.recovery-action:hover .action-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
/* =======================
|
||||
RESPONSIVE DESIGN
|
||||
======================= */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.llm-interaction-modal {
|
||||
min-width: auto;
|
||||
width: 90vw;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 15px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 15px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.llm-progress-header,
|
||||
.llm-progress-stages,
|
||||
.llm-progress-footer {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.llm-progress-footer {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.recovery-actions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================
|
||||
DARK MODE ADJUSTMENTS
|
||||
======================= */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.llm-interaction-overlay {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.countdown-fill {
|
||||
background: #f39c12;
|
||||
}
|
||||
|
||||
.auto-retry-section {
|
||||
background: linear-gradient(135deg, #f39c12, #d68910);
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================
|
||||
ANIMATIONS
|
||||
======================= */
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-running .stage-status-icon i {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-error-recovery-item {
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-running .stage-progress-fill {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--accent-color) 0%,
|
||||
var(--accent-color-lighter) 50%,
|
||||
var(--accent-color) 100%
|
||||
);
|
||||
background-size: 200px 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
511
apps/client/src/widgets/llm_chat/enhanced_tool_integration.ts
Normal file
511
apps/client/src/widgets/llm_chat/enhanced_tool_integration.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Enhanced Tool Integration
|
||||
*
|
||||
* Integrates tool preview, feedback, and error recovery into the LLM chat experience.
|
||||
*/
|
||||
|
||||
import server from "../../services/server.js";
|
||||
import { ToolPreviewUI, type ExecutionPlanData, type UserApproval } from "./tool_preview_ui.js";
|
||||
import { ToolFeedbackUI, type ToolProgressData, type ToolStepData } from "./tool_feedback_ui.js";
|
||||
|
||||
/**
|
||||
* Enhanced tool integration configuration
|
||||
*/
|
||||
export interface EnhancedToolConfig {
|
||||
enablePreview?: boolean;
|
||||
enableFeedback?: boolean;
|
||||
enableErrorRecovery?: boolean;
|
||||
requireConfirmation?: boolean;
|
||||
autoApproveTimeout?: number;
|
||||
showHistory?: boolean;
|
||||
showStatistics?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: EnhancedToolConfig = {
|
||||
enablePreview: true,
|
||||
enableFeedback: true,
|
||||
enableErrorRecovery: true,
|
||||
requireConfirmation: true,
|
||||
autoApproveTimeout: 30000, // 30 seconds
|
||||
showHistory: true,
|
||||
showStatistics: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced Tool Integration Manager
|
||||
*/
|
||||
export class EnhancedToolIntegration {
|
||||
private config: EnhancedToolConfig;
|
||||
private previewUI?: ToolPreviewUI;
|
||||
private feedbackUI?: ToolFeedbackUI;
|
||||
private container: HTMLElement;
|
||||
private eventHandlers: Map<string, Function[]> = new Map();
|
||||
private activeExecutions: Set<string> = new Set();
|
||||
|
||||
constructor(container: HTMLElement, config?: Partial<EnhancedToolConfig>) {
|
||||
this.container = container;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the integration
|
||||
*/
|
||||
private initialize(): void {
|
||||
// Create UI containers
|
||||
this.createUIContainers();
|
||||
|
||||
// Initialize UI components
|
||||
if (this.config.enablePreview) {
|
||||
const previewContainer = this.container.querySelector('.tool-preview-area') as HTMLElement;
|
||||
if (previewContainer) {
|
||||
this.previewUI = new ToolPreviewUI(previewContainer);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.enableFeedback) {
|
||||
const feedbackContainer = this.container.querySelector('.tool-feedback-area') as HTMLElement;
|
||||
if (feedbackContainer) {
|
||||
this.feedbackUI = new ToolFeedbackUI(feedbackContainer);
|
||||
|
||||
// Set up history and stats containers if enabled
|
||||
if (this.config.showHistory) {
|
||||
const historyContainer = this.container.querySelector('.tool-history-area') as HTMLElement;
|
||||
if (historyContainer) {
|
||||
this.feedbackUI.setHistoryContainer(historyContainer);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.showStatistics) {
|
||||
const statsContainer = this.container.querySelector('.tool-stats-area') as HTMLElement;
|
||||
if (statsContainer) {
|
||||
this.feedbackUI.setStatsContainer(statsContainer);
|
||||
this.loadStatistics();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
this.loadActiveExecutions();
|
||||
this.loadCircuitBreakerStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create UI containers
|
||||
*/
|
||||
private createUIContainers(): void {
|
||||
// Add enhanced tool UI areas if they don't exist
|
||||
if (!this.container.querySelector('.tool-preview-area')) {
|
||||
const previewArea = document.createElement('div');
|
||||
previewArea.className = 'tool-preview-area mb-3';
|
||||
this.container.appendChild(previewArea);
|
||||
}
|
||||
|
||||
if (!this.container.querySelector('.tool-feedback-area')) {
|
||||
const feedbackArea = document.createElement('div');
|
||||
feedbackArea.className = 'tool-feedback-area mb-3';
|
||||
this.container.appendChild(feedbackArea);
|
||||
}
|
||||
|
||||
if (this.config.showHistory && !this.container.querySelector('.tool-history-area')) {
|
||||
const historySection = document.createElement('div');
|
||||
historySection.className = 'tool-history-section mt-3';
|
||||
historySection.innerHTML = `
|
||||
<details class="small">
|
||||
<summary class="text-muted cursor-pointer">
|
||||
<i class="bx bx-history me-1"></i>
|
||||
Execution History
|
||||
</summary>
|
||||
<div class="tool-history-area mt-2"></div>
|
||||
</details>
|
||||
`;
|
||||
this.container.appendChild(historySection);
|
||||
}
|
||||
|
||||
if (this.config.showStatistics && !this.container.querySelector('.tool-stats-area')) {
|
||||
const statsSection = document.createElement('div');
|
||||
statsSection.className = 'tool-stats-section mt-3';
|
||||
statsSection.innerHTML = `
|
||||
<details class="small">
|
||||
<summary class="text-muted cursor-pointer">
|
||||
<i class="bx bx-bar-chart me-1"></i>
|
||||
Tool Statistics
|
||||
</summary>
|
||||
<div class="tool-stats-area mt-2"></div>
|
||||
</details>
|
||||
`;
|
||||
this.container.appendChild(statsSection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tool preview request
|
||||
*/
|
||||
public async handleToolPreview(toolCalls: any[]): Promise<UserApproval | null> {
|
||||
if (!this.config.enablePreview || !this.previewUI) {
|
||||
// Auto-approve if preview is disabled
|
||||
return {
|
||||
planId: `auto-${Date.now()}`,
|
||||
approved: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get preview from server
|
||||
const response = await server.post<ExecutionPlanData>('api/llm-tools/preview', {
|
||||
toolCalls
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
console.error('Failed to get tool preview');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show preview and wait for user approval
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const handleApproval = (approval: UserApproval) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Send approval to server
|
||||
server.post(`api/llm-tools/preview/${approval.planId}/approval`, approval)
|
||||
.catch(error => console.error('Failed to record approval:', error));
|
||||
|
||||
resolve(approval);
|
||||
};
|
||||
|
||||
// Show preview UI
|
||||
this.previewUI!.showPreview(response, handleApproval);
|
||||
|
||||
// Auto-approve after timeout if configured
|
||||
if (this.config.autoApproveTimeout && response.requiresConfirmation) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
const autoApproval: UserApproval = {
|
||||
planId: response.id,
|
||||
approved: true
|
||||
};
|
||||
handleApproval(autoApproval);
|
||||
}, this.config.autoApproveTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling tool preview:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tool execution tracking
|
||||
*/
|
||||
public startToolExecution(
|
||||
executionId: string,
|
||||
toolName: string,
|
||||
displayName?: string
|
||||
): void {
|
||||
if (!this.config.enableFeedback || !this.feedbackUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeExecutions.add(executionId);
|
||||
this.feedbackUI.startExecution(executionId, toolName, displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tool execution progress
|
||||
*/
|
||||
public updateToolProgress(data: ToolProgressData): void {
|
||||
if (!this.config.enableFeedback || !this.feedbackUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.feedbackUI.updateProgress(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tool execution step
|
||||
*/
|
||||
public addToolStep(data: ToolStepData): void {
|
||||
if (!this.config.enableFeedback || !this.feedbackUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.feedbackUI.addStep(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete tool execution
|
||||
*/
|
||||
public completeToolExecution(
|
||||
executionId: string,
|
||||
status: 'success' | 'error' | 'cancelled' | 'timeout',
|
||||
result?: any,
|
||||
error?: string
|
||||
): void {
|
||||
if (!this.config.enableFeedback || !this.feedbackUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeExecutions.delete(executionId);
|
||||
this.feedbackUI.completeExecution(executionId, status, result, error);
|
||||
|
||||
// Refresh statistics
|
||||
if (this.config.showStatistics) {
|
||||
setTimeout(() => this.loadStatistics(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel tool execution
|
||||
*/
|
||||
public async cancelToolExecution(executionId: string, reason?: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await server.post<any>(`api/llm-tools/executions/${executionId}/cancel`, {
|
||||
reason
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
this.completeToolExecution(executionId, 'cancelled', undefined, reason);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel execution:', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load active executions
|
||||
*/
|
||||
private async loadActiveExecutions(): Promise<void> {
|
||||
if (!this.config.enableFeedback) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const executions = await server.get<any[]>('api/llm-tools/executions/active');
|
||||
|
||||
if (executions && Array.isArray(executions)) {
|
||||
executions.forEach(exec => {
|
||||
if (!this.activeExecutions.has(exec.id)) {
|
||||
this.startToolExecution(exec.id, exec.toolName);
|
||||
// Restore progress if available
|
||||
if (exec.progress) {
|
||||
this.updateToolProgress({
|
||||
executionId: exec.id,
|
||||
...exec.progress
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load active executions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load execution statistics
|
||||
*/
|
||||
private async loadStatistics(): Promise<void> {
|
||||
if (!this.config.showStatistics) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await server.get<any>('api/llm-tools/executions/stats');
|
||||
|
||||
if (stats) {
|
||||
this.displayStatistics(stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load statistics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display statistics
|
||||
*/
|
||||
private displayStatistics(stats: any): void {
|
||||
const container = this.container.querySelector('.tool-stats-area') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="tool-stats-container">
|
||||
<div class="tool-stat-item">
|
||||
<div class="tool-stat-value">${stats.totalExecutions}</div>
|
||||
<div class="tool-stat-label">Total</div>
|
||||
</div>
|
||||
<div class="tool-stat-item">
|
||||
<div class="tool-stat-value text-success">${stats.successfulExecutions}</div>
|
||||
<div class="tool-stat-label">Success</div>
|
||||
</div>
|
||||
<div class="tool-stat-item">
|
||||
<div class="tool-stat-value text-danger">${stats.failedExecutions}</div>
|
||||
<div class="tool-stat-label">Failed</div>
|
||||
</div>
|
||||
<div class="tool-stat-item">
|
||||
<div class="tool-stat-value">${this.formatDuration(stats.averageDuration)}</div>
|
||||
<div class="tool-stat-label">Avg Time</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add tool-specific statistics if available
|
||||
if (stats.toolStatistics && Object.keys(stats.toolStatistics).length > 0) {
|
||||
const toolStatsHtml = Object.entries(stats.toolStatistics)
|
||||
.map(([toolName, toolStats]: [string, any]) => `
|
||||
<tr>
|
||||
<td>${toolName}</td>
|
||||
<td>${toolStats.count}</td>
|
||||
<td>${toolStats.successRate}%</td>
|
||||
<td>${this.formatDuration(toolStats.averageDuration)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML += `
|
||||
<div class="mt-3">
|
||||
<h6 class="small text-muted">Per-Tool Statistics</h6>
|
||||
<table class="table table-sm small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tool</th>
|
||||
<th>Count</th>
|
||||
<th>Success</th>
|
||||
<th>Avg Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${toolStatsHtml}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load circuit breaker status
|
||||
*/
|
||||
private async loadCircuitBreakerStatus(): Promise<void> {
|
||||
try {
|
||||
const statuses = await server.get<any[]>('api/llm-tools/circuit-breakers');
|
||||
|
||||
if (statuses && Array.isArray(statuses)) {
|
||||
this.displayCircuitBreakerStatus(statuses);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load circuit breaker status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display circuit breaker status
|
||||
*/
|
||||
private displayCircuitBreakerStatus(statuses: any[]): void {
|
||||
const openBreakers = statuses.filter(s => s.state === 'open');
|
||||
const halfOpenBreakers = statuses.filter(s => s.state === 'half_open');
|
||||
|
||||
if (openBreakers.length > 0 || halfOpenBreakers.length > 0) {
|
||||
const alertContainer = document.createElement('div');
|
||||
alertContainer.className = 'circuit-breaker-alerts mb-3';
|
||||
|
||||
if (openBreakers.length > 0) {
|
||||
alertContainer.innerHTML += `
|
||||
<div class="alert alert-danger small py-2">
|
||||
<i class="bx bx-error-circle me-1"></i>
|
||||
<strong>Circuit Breakers Open:</strong>
|
||||
${openBreakers.map(b => b.toolName).join(', ')}
|
||||
<button class="btn btn-sm btn-link reset-breakers-btn float-end py-0">
|
||||
Reset All
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (halfOpenBreakers.length > 0) {
|
||||
alertContainer.innerHTML += `
|
||||
<div class="alert alert-warning small py-2">
|
||||
<i class="bx bx-error me-1"></i>
|
||||
<strong>Circuit Breakers Half-Open:</strong>
|
||||
${halfOpenBreakers.map(b => b.toolName).join(', ')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add to container
|
||||
const existingAlerts = this.container.querySelector('.circuit-breaker-alerts');
|
||||
if (existingAlerts) {
|
||||
existingAlerts.replaceWith(alertContainer);
|
||||
} else {
|
||||
this.container.insertBefore(alertContainer, this.container.firstChild);
|
||||
}
|
||||
|
||||
// Add reset handler
|
||||
const resetBtn = alertContainer.querySelector('.reset-breakers-btn');
|
||||
resetBtn?.addEventListener('click', () => this.resetAllCircuitBreakers(openBreakers));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all circuit breakers
|
||||
*/
|
||||
private async resetAllCircuitBreakers(breakers: any[]): Promise<void> {
|
||||
for (const breaker of breakers) {
|
||||
try {
|
||||
await server.post(`api/llm-tools/circuit-breakers/${breaker.toolName}/reset`, {});
|
||||
} catch (error) {
|
||||
console.error(`Failed to reset circuit breaker for ${breaker.toolName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload status
|
||||
this.loadCircuitBreakerStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration
|
||||
*/
|
||||
private formatDuration(milliseconds: number): string {
|
||||
if (!milliseconds || milliseconds === 0) return '0ms';
|
||||
if (milliseconds < 1000) {
|
||||
return `${Math.round(milliseconds)}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${(milliseconds / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.eventHandlers.clear();
|
||||
this.activeExecutions.clear();
|
||||
|
||||
if (this.feedbackUI) {
|
||||
this.feedbackUI.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enhanced tool integration
|
||||
*/
|
||||
export function createEnhancedToolIntegration(
|
||||
container: HTMLElement,
|
||||
config?: Partial<EnhancedToolConfig>
|
||||
): EnhancedToolIntegration {
|
||||
return new EnhancedToolIntegration(container, config);
|
||||
}
|
||||
451
apps/client/src/widgets/llm_chat/error_recovery_manager.ts
Normal file
451
apps/client/src/widgets/llm_chat/error_recovery_manager.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
interface ErrorRecoveryOptions {
|
||||
errorId: string;
|
||||
toolName: string;
|
||||
message: string;
|
||||
errorType: string;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
recoveryActions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
action: 'retry' | 'skip' | 'modify' | 'abort' | 'alternative';
|
||||
parameters?: Record<string, unknown>;
|
||||
}>;
|
||||
autoRetryIn?: number; // seconds
|
||||
context?: {
|
||||
originalParams?: Record<string, unknown>;
|
||||
previousAttempts?: string[];
|
||||
suggestions?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorRecoveryResponse {
|
||||
errorId: string;
|
||||
action: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Recovery Manager for LLM Chat
|
||||
* Handles sophisticated error recovery with multiple strategies and user guidance
|
||||
*/
|
||||
export class ErrorRecoveryManager {
|
||||
private activeErrors: Map<string, ErrorRecoveryOptions> = new Map();
|
||||
private responseCallbacks: Map<string, (response: ErrorRecoveryResponse) => void> = new Map();
|
||||
private container: HTMLElement;
|
||||
|
||||
constructor(parentElement: HTMLElement) {
|
||||
this.container = this.createErrorContainer();
|
||||
parentElement.appendChild(this.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error recovery container
|
||||
*/
|
||||
private createErrorContainer(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'llm-error-recovery-container';
|
||||
container.style.display = 'none';
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error recovery options
|
||||
*/
|
||||
public async showErrorRecovery(options: ErrorRecoveryOptions): Promise<ErrorRecoveryResponse> {
|
||||
this.activeErrors.set(options.errorId, options);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.responseCallbacks.set(options.errorId, resolve);
|
||||
|
||||
const errorElement = this.createErrorElement(options);
|
||||
this.container.appendChild(errorElement);
|
||||
this.container.style.display = 'block';
|
||||
|
||||
// Start auto-retry countdown if enabled
|
||||
if (options.autoRetryIn && options.autoRetryIn > 0) {
|
||||
this.startAutoRetryCountdown(options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error recovery element
|
||||
*/
|
||||
private createErrorElement(options: ErrorRecoveryOptions): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'llm-error-recovery-item';
|
||||
element.setAttribute('data-error-id', options.errorId);
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="error-header">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="error-title">
|
||||
<div class="error-tool-name">${options.toolName} Failed</div>
|
||||
<div class="error-attempt-info">Attempt ${options.attempt}/${options.maxAttempts}</div>
|
||||
</div>
|
||||
<div class="error-type-badge ${this.getErrorTypeBadgeClass(options.errorType)}">
|
||||
${options.errorType}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-body">
|
||||
<div class="error-message">
|
||||
<div class="error-message-label">Error Details:</div>
|
||||
<div class="error-message-content">${options.message}</div>
|
||||
</div>
|
||||
|
||||
${this.createContextSection(options.context)}
|
||||
${this.createAutoRetrySection(options.autoRetryIn)}
|
||||
|
||||
<div class="recovery-actions">
|
||||
<div class="recovery-actions-label">Recovery Options:</div>
|
||||
<div class="recovery-actions-grid">
|
||||
${this.createRecoveryActions(options)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachErrorEvents(element, options);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context section
|
||||
*/
|
||||
private createContextSection(context?: ErrorRecoveryOptions['context']): string {
|
||||
if (!context) return '';
|
||||
|
||||
return `
|
||||
<div class="error-context">
|
||||
${context.originalParams ? `
|
||||
<div class="context-section">
|
||||
<div class="context-label">Original Parameters:</div>
|
||||
<div class="context-content">
|
||||
${this.formatParameters(context.originalParams)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${context.previousAttempts && context.previousAttempts.length > 0 ? `
|
||||
<div class="context-section">
|
||||
<div class="context-label">Previous Attempts:</div>
|
||||
<div class="context-content">
|
||||
<ul class="previous-attempts-list">
|
||||
${context.previousAttempts.map(attempt => `<li>${attempt}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${context.suggestions && context.suggestions.length > 0 ? `
|
||||
<div class="context-section">
|
||||
<div class="context-label">Suggestions:</div>
|
||||
<div class="context-content">
|
||||
<ul class="suggestions-list">
|
||||
${context.suggestions.map(suggestion => `<li>${suggestion}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auto-retry section
|
||||
*/
|
||||
private createAutoRetrySection(autoRetryIn?: number): string {
|
||||
if (!autoRetryIn || autoRetryIn <= 0) return '';
|
||||
|
||||
return `
|
||||
<div class="auto-retry-section">
|
||||
<div class="auto-retry-info">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span>Auto-retry in <span class="retry-countdown">${autoRetryIn}</span> seconds</span>
|
||||
</div>
|
||||
<div class="auto-retry-progress">
|
||||
<div class="retry-progress-bar">
|
||||
<div class="retry-progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary cancel-auto-retry">Cancel Auto-retry</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create recovery actions
|
||||
*/
|
||||
private createRecoveryActions(options: ErrorRecoveryOptions): string {
|
||||
return options.recoveryActions.map(action => {
|
||||
const actionClass = this.getActionClass(action.action);
|
||||
const icon = this.getActionIcon(action.action);
|
||||
|
||||
return `
|
||||
<div class="recovery-action ${actionClass}" data-action-id="${action.id}">
|
||||
<div class="action-icon">
|
||||
<i class="${icon}"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<div class="action-label">${action.label}</div>
|
||||
${action.description ? `<div class="action-description">${action.description}</div>` : ''}
|
||||
</div>
|
||||
<div class="action-arrow">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parameters for display
|
||||
*/
|
||||
private formatParameters(params: Record<string, unknown>): string {
|
||||
return Object.entries(params).map(([key, value]) => {
|
||||
let displayValue: string;
|
||||
if (typeof value === 'string') {
|
||||
displayValue = value.length > 50 ? value.substring(0, 50) + '...' : value;
|
||||
displayValue = `"${displayValue}"`;
|
||||
} else if (typeof value === 'object') {
|
||||
displayValue = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
}
|
||||
|
||||
return `<div class="param-item">
|
||||
<span class="param-key">${key}:</span>
|
||||
<span class="param-value">${displayValue}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error type badge class
|
||||
*/
|
||||
private getErrorTypeBadgeClass(errorType: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'NetworkError': 'badge-warning',
|
||||
'TimeoutError': 'badge-warning',
|
||||
'ValidationError': 'badge-danger',
|
||||
'NotFoundError': 'badge-info',
|
||||
'PermissionError': 'badge-danger',
|
||||
'RateLimitError': 'badge-warning',
|
||||
'UnknownError': 'badge-secondary'
|
||||
};
|
||||
|
||||
return typeMap[errorType] || 'badge-secondary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action class
|
||||
*/
|
||||
private getActionClass(action: string): string {
|
||||
const actionMap: Record<string, string> = {
|
||||
'retry': 'action-retry',
|
||||
'skip': 'action-skip',
|
||||
'modify': 'action-modify',
|
||||
'abort': 'action-abort',
|
||||
'alternative': 'action-alternative'
|
||||
};
|
||||
|
||||
return actionMap[action] || 'action-default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action icon
|
||||
*/
|
||||
private getActionIcon(action: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
'retry': 'fas fa-redo',
|
||||
'skip': 'fas fa-forward',
|
||||
'modify': 'fas fa-edit',
|
||||
'abort': 'fas fa-times',
|
||||
'alternative': 'fas fa-route'
|
||||
};
|
||||
|
||||
return iconMap[action] || 'fas fa-cog';
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach error events
|
||||
*/
|
||||
private attachErrorEvents(element: HTMLElement, options: ErrorRecoveryOptions): void {
|
||||
// Recovery action clicks
|
||||
const actions = element.querySelectorAll('.recovery-action');
|
||||
actions.forEach(action => {
|
||||
action.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const actionId = target.getAttribute('data-action-id');
|
||||
if (actionId) {
|
||||
const recoveryAction = options.recoveryActions.find(a => a.id === actionId);
|
||||
if (recoveryAction) {
|
||||
this.executeRecoveryAction(options.errorId, recoveryAction);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel auto-retry
|
||||
const cancelAutoRetry = element.querySelector('.cancel-auto-retry');
|
||||
if (cancelAutoRetry) {
|
||||
cancelAutoRetry.addEventListener('click', () => {
|
||||
this.cancelAutoRetry(options.errorId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-retry countdown
|
||||
*/
|
||||
private startAutoRetryCountdown(options: ErrorRecoveryOptions): void {
|
||||
if (!options.autoRetryIn) return;
|
||||
|
||||
const element = this.container.querySelector(`[data-error-id="${options.errorId}"]`) as HTMLElement;
|
||||
if (!element) return;
|
||||
|
||||
const countdownElement = element.querySelector('.retry-countdown') as HTMLElement;
|
||||
const progressFill = element.querySelector('.retry-progress-fill') as HTMLElement;
|
||||
|
||||
let remainingTime = options.autoRetryIn;
|
||||
const totalTime = options.autoRetryIn;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
remainingTime--;
|
||||
|
||||
if (countdownElement) {
|
||||
countdownElement.textContent = remainingTime.toString();
|
||||
}
|
||||
|
||||
if (progressFill) {
|
||||
const progress = ((totalTime - remainingTime) / totalTime) * 100;
|
||||
progressFill.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
if (remainingTime <= 0) {
|
||||
clearInterval(interval);
|
||||
// Auto-execute retry
|
||||
const retryAction = options.recoveryActions.find(a => a.action === 'retry');
|
||||
if (retryAction) {
|
||||
this.executeRecoveryAction(options.errorId, retryAction);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Store interval for potential cancellation
|
||||
element.setAttribute('data-retry-interval', interval.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel auto-retry
|
||||
*/
|
||||
private cancelAutoRetry(errorId: string): void {
|
||||
const element = this.container.querySelector(`[data-error-id="${errorId}"]`) as HTMLElement;
|
||||
if (!element) return;
|
||||
|
||||
const intervalId = element.getAttribute('data-retry-interval');
|
||||
if (intervalId) {
|
||||
clearInterval(parseInt(intervalId));
|
||||
element.removeAttribute('data-retry-interval');
|
||||
}
|
||||
|
||||
// Hide auto-retry section
|
||||
const autoRetrySection = element.querySelector('.auto-retry-section') as HTMLElement;
|
||||
if (autoRetrySection) {
|
||||
autoRetrySection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute recovery action
|
||||
*/
|
||||
private executeRecoveryAction(errorId: string, action: ErrorRecoveryOptions['recoveryActions'][0]): void {
|
||||
const callback = this.responseCallbacks.get(errorId);
|
||||
if (!callback) return;
|
||||
|
||||
const response: ErrorRecoveryResponse = {
|
||||
errorId,
|
||||
action: action.action,
|
||||
parameters: action.parameters,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Clean up
|
||||
this.activeErrors.delete(errorId);
|
||||
this.responseCallbacks.delete(errorId);
|
||||
this.removeErrorElement(errorId);
|
||||
|
||||
// Call callback
|
||||
callback(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove error element
|
||||
*/
|
||||
private removeErrorElement(errorId: string): void {
|
||||
const element = this.container.querySelector(`[data-error-id="${errorId}"]`) as HTMLElement;
|
||||
if (element) {
|
||||
element.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
element.remove();
|
||||
|
||||
// Hide container if no more errors
|
||||
if (this.container.children.length === 0) {
|
||||
this.container.style.display = 'none';
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all errors
|
||||
*/
|
||||
public clearAllErrors(): void {
|
||||
this.activeErrors.clear();
|
||||
this.responseCallbacks.clear();
|
||||
this.container.innerHTML = '';
|
||||
this.container.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active error count
|
||||
*/
|
||||
public getActiveErrorCount(): number {
|
||||
return this.activeErrors.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error recovery is active
|
||||
*/
|
||||
public hasActiveErrors(): boolean {
|
||||
return this.activeErrors.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update error context (for adding new information)
|
||||
*/
|
||||
public updateErrorContext(errorId: string, newContext: Partial<ErrorRecoveryOptions['context']>): void {
|
||||
const options = this.activeErrors.get(errorId);
|
||||
if (!options) return;
|
||||
|
||||
options.context = { ...options.context, ...newContext };
|
||||
|
||||
// Re-render the context section
|
||||
const element = this.container.querySelector(`[data-error-id="${errorId}"]`) as HTMLElement;
|
||||
if (element) {
|
||||
const contextContainer = element.querySelector('.error-context') as HTMLElement;
|
||||
if (contextContainer) {
|
||||
contextContainer.outerHTML = this.createContextSection(options.context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { ErrorRecoveryOptions, ErrorRecoveryResponse };
|
||||
529
apps/client/src/widgets/llm_chat/interaction_manager.ts
Normal file
529
apps/client/src/widgets/llm_chat/interaction_manager.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
interface UserInteractionRequest {
|
||||
id: string;
|
||||
type: 'confirmation' | 'choice' | 'input' | 'tool_confirmation';
|
||||
title: string;
|
||||
message: string;
|
||||
options?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
style?: 'primary' | 'secondary' | 'warning' | 'danger';
|
||||
action?: string;
|
||||
}>;
|
||||
defaultValue?: string;
|
||||
timeout?: number; // milliseconds
|
||||
tool?: {
|
||||
name: string;
|
||||
description: string;
|
||||
arguments: Record<string, unknown>;
|
||||
riskLevel?: 'low' | 'medium' | 'high';
|
||||
};
|
||||
}
|
||||
|
||||
interface UserInteractionResponse {
|
||||
id: string;
|
||||
response: string;
|
||||
value?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Interaction Manager for LLM Chat
|
||||
* Handles confirmations, choices, and input prompts during LLM operations
|
||||
*/
|
||||
export class InteractionManager {
|
||||
private activeInteractions: Map<string, UserInteractionRequest> = new Map();
|
||||
private responseCallbacks: Map<string, (response: UserInteractionResponse) => void> = new Map();
|
||||
private modalContainer: HTMLElement;
|
||||
private overlay: HTMLElement;
|
||||
|
||||
constructor(parentElement: HTMLElement) {
|
||||
this.createModalContainer(parentElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create modal container and overlay
|
||||
*/
|
||||
private createModalContainer(parentElement: HTMLElement): void {
|
||||
// Create overlay
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'llm-interaction-overlay';
|
||||
this.overlay.style.display = 'none';
|
||||
|
||||
// Create modal container
|
||||
this.modalContainer = document.createElement('div');
|
||||
this.modalContainer.className = 'llm-interaction-modal-container';
|
||||
|
||||
this.overlay.appendChild(this.modalContainer);
|
||||
parentElement.appendChild(this.overlay);
|
||||
|
||||
// Close on overlay click
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) {
|
||||
this.cancelAllInteractions();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.hasActiveInteractions()) {
|
||||
this.cancelAllInteractions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request user interaction
|
||||
*/
|
||||
public async requestUserInteraction(request: UserInteractionRequest): Promise<UserInteractionResponse> {
|
||||
this.activeInteractions.set(request.id, request);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Set up response callback
|
||||
this.responseCallbacks.set(request.id, resolve);
|
||||
|
||||
// Create and show modal
|
||||
const modal = this.createInteractionModal(request);
|
||||
this.showModal(modal);
|
||||
|
||||
// Set up timeout if specified
|
||||
if (request.timeout && request.timeout > 0) {
|
||||
setTimeout(() => {
|
||||
if (this.activeInteractions.has(request.id)) {
|
||||
this.handleTimeout(request.id);
|
||||
}
|
||||
}, request.timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create interaction modal based on request type
|
||||
*/
|
||||
private createInteractionModal(request: UserInteractionRequest): HTMLElement {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = `llm-interaction-modal llm-interaction-${request.type}`;
|
||||
modal.setAttribute('data-interaction-id', request.id);
|
||||
|
||||
switch (request.type) {
|
||||
case 'tool_confirmation':
|
||||
return this.createToolConfirmationModal(modal, request);
|
||||
case 'confirmation':
|
||||
return this.createConfirmationModal(modal, request);
|
||||
case 'choice':
|
||||
return this.createChoiceModal(modal, request);
|
||||
case 'input':
|
||||
return this.createInputModal(modal, request);
|
||||
default:
|
||||
return this.createGenericModal(modal, request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tool confirmation modal
|
||||
*/
|
||||
private createToolConfirmationModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
const tool = request.tool!;
|
||||
const riskClass = tool.riskLevel ? `risk-${tool.riskLevel}` : '';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header ${riskClass}">
|
||||
<div class="modal-title">
|
||||
<i class="fas fa-tools"></i>
|
||||
Tool Execution Confirmation
|
||||
</div>
|
||||
<div class="risk-indicator ${riskClass}">
|
||||
<span class="risk-label">${(tool.riskLevel || 'medium').toUpperCase()} RISK</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">${tool.name}</div>
|
||||
<div class="tool-description">${tool.description}</div>
|
||||
</div>
|
||||
<div class="tool-arguments">
|
||||
<div class="arguments-label">Parameters:</div>
|
||||
<div class="arguments-content">
|
||||
${this.formatToolArguments(tool.arguments)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="confirmation-message">${request.message}</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
${this.createActionButtons(request)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachButtonEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create confirmation modal
|
||||
*/
|
||||
private createConfirmationModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
${request.title}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="confirmation-message">${request.message}</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
${this.createActionButtons(request)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachButtonEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create choice modal
|
||||
*/
|
||||
private createChoiceModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<i class="fas fa-list"></i>
|
||||
${request.title}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="choice-message">${request.message}</div>
|
||||
<div class="choice-options">
|
||||
${(request.options || []).map(option => `
|
||||
<div class="choice-option" data-option-id="${option.id}">
|
||||
<div class="option-label">${option.label}</div>
|
||||
${option.description ? `<div class="option-description">${option.description}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary cancel-btn">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachChoiceEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create input modal
|
||||
*/
|
||||
private createInputModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<i class="fas fa-edit"></i>
|
||||
${request.title}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="input-message">${request.message}</div>
|
||||
<div class="input-field">
|
||||
<input type="text" class="form-control" placeholder="Enter your response..."
|
||||
value="${request.defaultValue || ''}" autofocus>
|
||||
</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary submit-btn">Submit</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachInputEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create generic modal
|
||||
*/
|
||||
private createGenericModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">${request.title}</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="generic-message">${request.message}</div>
|
||||
${this.createTimeoutIndicator(request.timeout)}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
${this.createActionButtons(request)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachButtonEvents(modal, request);
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool arguments for display
|
||||
*/
|
||||
private formatToolArguments(args: Record<string, unknown>): string {
|
||||
const formatted = Object.entries(args).map(([key, value]) => {
|
||||
let displayValue: string;
|
||||
if (typeof value === 'string') {
|
||||
displayValue = value.length > 100 ? value.substring(0, 100) + '...' : value;
|
||||
displayValue = `"${displayValue}"`;
|
||||
} else if (typeof value === 'object') {
|
||||
displayValue = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
}
|
||||
|
||||
return `<div class="argument-item">
|
||||
<span class="argument-key">${key}:</span>
|
||||
<span class="argument-value">${displayValue}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return formatted || '<div class="no-arguments">No parameters</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create action buttons based on request options
|
||||
*/
|
||||
private createActionButtons(request: UserInteractionRequest): string {
|
||||
if (request.options && request.options.length > 0) {
|
||||
return request.options.map(option => `
|
||||
<button class="btn btn-${option.style || 'secondary'} action-btn"
|
||||
data-action="${option.id}" data-response="${option.action || option.id}">
|
||||
${option.label}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
// Default confirmation buttons
|
||||
return `
|
||||
<button class="btn btn-secondary cancel-btn" data-response="cancel">Cancel</button>
|
||||
<button class="btn btn-primary confirm-btn" data-response="confirm">Confirm</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeout indicator
|
||||
*/
|
||||
private createTimeoutIndicator(timeout?: number): string {
|
||||
if (!timeout || timeout <= 0) return '';
|
||||
|
||||
return `
|
||||
<div class="timeout-indicator">
|
||||
<div class="timeout-label">Auto-cancel in:</div>
|
||||
<div class="timeout-countdown" data-timeout="${timeout}">
|
||||
<div class="countdown-bar">
|
||||
<div class="countdown-fill"></div>
|
||||
</div>
|
||||
<div class="countdown-text">${Math.ceil(timeout / 1000)}s</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show modal
|
||||
*/
|
||||
private showModal(modal: HTMLElement): void {
|
||||
this.modalContainer.innerHTML = '';
|
||||
this.modalContainer.appendChild(modal);
|
||||
this.overlay.style.display = 'flex';
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => {
|
||||
this.overlay.classList.add('show');
|
||||
modal.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
// Start timeout countdown if present
|
||||
this.startTimeoutCountdown(modal);
|
||||
|
||||
// Focus first input if present
|
||||
const firstInput = modal.querySelector('input, button') as HTMLElement;
|
||||
if (firstInput) {
|
||||
firstInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide modal
|
||||
*/
|
||||
private hideModal(): void {
|
||||
this.overlay.classList.remove('show');
|
||||
const modal = this.modalContainer.querySelector('.llm-interaction-modal') as HTMLElement;
|
||||
if (modal) {
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.overlay.style.display = 'none';
|
||||
this.modalContainer.innerHTML = '';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach button events
|
||||
*/
|
||||
private attachButtonEvents(modal: HTMLElement, request: UserInteractionRequest): void {
|
||||
const buttons = modal.querySelectorAll('.action-btn, .confirm-btn, .cancel-btn');
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const response = target.getAttribute('data-response') || 'cancel';
|
||||
this.respondToInteraction(request.id, response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach choice events
|
||||
*/
|
||||
private attachChoiceEvents(modal: HTMLElement, request: UserInteractionRequest): void {
|
||||
const options = modal.querySelectorAll('.choice-option');
|
||||
options.forEach(option => {
|
||||
option.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const optionId = target.getAttribute('data-option-id');
|
||||
if (optionId) {
|
||||
this.respondToInteraction(request.id, optionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
const cancelBtn = modal.querySelector('.cancel-btn');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.respondToInteraction(request.id, 'cancel');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach input events
|
||||
*/
|
||||
private attachInputEvents(modal: HTMLElement, request: UserInteractionRequest): void {
|
||||
const input = modal.querySelector('input') as HTMLInputElement;
|
||||
const submitBtn = modal.querySelector('.submit-btn') as HTMLElement;
|
||||
const cancelBtn = modal.querySelector('.cancel-btn') as HTMLElement;
|
||||
|
||||
const submitValue = () => {
|
||||
const value = input.value.trim();
|
||||
this.respondToInteraction(request.id, 'submit', value);
|
||||
};
|
||||
|
||||
submitBtn.addEventListener('click', submitValue);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitValue();
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.respondToInteraction(request.id, 'cancel');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start timeout countdown
|
||||
*/
|
||||
private startTimeoutCountdown(modal: HTMLElement): void {
|
||||
const countdown = modal.querySelector('.timeout-countdown') as HTMLElement;
|
||||
if (!countdown) return;
|
||||
|
||||
const timeout = parseInt(countdown.getAttribute('data-timeout') || '0');
|
||||
if (timeout <= 0) return;
|
||||
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, timeout - elapsed);
|
||||
const progress = (elapsed / timeout) * 100;
|
||||
|
||||
// Update countdown bar
|
||||
const fill = countdown.querySelector('.countdown-fill') as HTMLElement;
|
||||
if (fill) {
|
||||
fill.style.width = `${Math.min(100, progress)}%`;
|
||||
}
|
||||
|
||||
// Update countdown text
|
||||
const text = countdown.querySelector('.countdown-text') as HTMLElement;
|
||||
if (text) {
|
||||
text.textContent = `${Math.ceil(remaining / 1000)}s`;
|
||||
}
|
||||
|
||||
// Stop when timeout reached
|
||||
if (remaining <= 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Store interval for cleanup
|
||||
countdown.setAttribute('data-interval', interval.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to interaction
|
||||
*/
|
||||
private respondToInteraction(id: string, response: string, value?: any): void {
|
||||
const callback = this.responseCallbacks.get(id);
|
||||
if (!callback) return;
|
||||
|
||||
const interactionResponse: UserInteractionResponse = {
|
||||
id,
|
||||
response,
|
||||
value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Clean up
|
||||
this.activeInteractions.delete(id);
|
||||
this.responseCallbacks.delete(id);
|
||||
this.hideModal();
|
||||
|
||||
// Call callback
|
||||
callback(interactionResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle interaction timeout
|
||||
*/
|
||||
private handleTimeout(id: string): void {
|
||||
this.respondToInteraction(id, 'timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all active interactions
|
||||
*/
|
||||
public cancelAllInteractions(): void {
|
||||
const activeIds = Array.from(this.activeInteractions.keys());
|
||||
activeIds.forEach(id => {
|
||||
this.respondToInteraction(id, 'cancel');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are active interactions
|
||||
*/
|
||||
public hasActiveInteractions(): boolean {
|
||||
return this.activeInteractions.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active interaction count
|
||||
*/
|
||||
public getActiveInteractionCount(): number {
|
||||
return this.activeInteractions.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { UserInteractionRequest, UserInteractionResponse };
|
||||
387
apps/client/src/widgets/llm_chat/progress_indicator.ts
Normal file
387
apps/client/src/widgets/llm_chat/progress_indicator.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
interface ProgressStage {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
progress: number; // 0-100
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
message?: string;
|
||||
estimatedDuration?: number;
|
||||
}
|
||||
|
||||
interface ProgressUpdate {
|
||||
stageId: string;
|
||||
progress: number;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
message?: string;
|
||||
estimatedTimeRemaining?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced Progress Indicator for LLM Chat Operations
|
||||
* Displays multi-stage progress with progress bars, timing, and status updates
|
||||
*/
|
||||
export class ProgressIndicator {
|
||||
private container: HTMLElement;
|
||||
private stages: Map<string, ProgressStage> = new Map();
|
||||
private overallProgress: number = 0;
|
||||
private isVisible: boolean = false;
|
||||
|
||||
constructor(parentElement: HTMLElement) {
|
||||
this.container = this.createProgressContainer();
|
||||
parentElement.appendChild(this.container);
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main progress container
|
||||
*/
|
||||
private createProgressContainer(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'llm-progress-container';
|
||||
container.innerHTML = `
|
||||
<div class="llm-progress-header">
|
||||
<div class="llm-progress-title">Processing...</div>
|
||||
<div class="llm-progress-overall">
|
||||
<div class="llm-progress-bar-container">
|
||||
<div class="llm-progress-bar-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="llm-progress-percentage">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="llm-progress-stages"></div>
|
||||
<div class="llm-progress-footer">
|
||||
<div class="llm-progress-time-info">
|
||||
<span class="elapsed-time">Elapsed: 0s</span>
|
||||
<span class="estimated-remaining">Est. remaining: --</span>
|
||||
</div>
|
||||
<button class="llm-progress-cancel-btn" title="Cancel operation">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the progress indicator
|
||||
*/
|
||||
public show(): void {
|
||||
if (!this.isVisible) {
|
||||
this.container.style.display = 'block';
|
||||
this.container.classList.add('fade-in');
|
||||
this.isVisible = true;
|
||||
this.startElapsedTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the progress indicator
|
||||
*/
|
||||
public hide(): void {
|
||||
if (this.isVisible) {
|
||||
this.container.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
this.container.style.display = 'none';
|
||||
this.container.classList.remove('fade-in', 'fade-out');
|
||||
this.isVisible = false;
|
||||
this.stopElapsedTimer();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new progress stage
|
||||
*/
|
||||
public addStage(stageId: string, label: string, estimatedDuration?: number): void {
|
||||
const stage: ProgressStage = {
|
||||
id: stageId,
|
||||
label,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
estimatedDuration
|
||||
};
|
||||
|
||||
this.stages.set(stageId, stage);
|
||||
this.renderStage(stage);
|
||||
this.updateOverallProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress for a specific stage
|
||||
*/
|
||||
public updateStageProgress(update: ProgressUpdate): void {
|
||||
const stage = this.stages.get(update.stageId);
|
||||
if (!stage) return;
|
||||
|
||||
// Update stage data
|
||||
stage.progress = Math.max(0, Math.min(100, update.progress));
|
||||
stage.status = update.status;
|
||||
stage.message = update.message;
|
||||
|
||||
// Set timing
|
||||
if (update.status === 'running' && !stage.startTime) {
|
||||
stage.startTime = Date.now();
|
||||
} else if ((update.status === 'completed' || update.status === 'failed') && stage.startTime && !stage.endTime) {
|
||||
stage.endTime = Date.now();
|
||||
}
|
||||
|
||||
this.renderStage(stage);
|
||||
this.updateOverallProgress();
|
||||
|
||||
if (update.estimatedTimeRemaining !== undefined) {
|
||||
this.updateEstimatedTime(update.estimatedTimeRemaining);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage as completed
|
||||
*/
|
||||
public completeStage(stageId: string): void {
|
||||
this.updateStageProgress({
|
||||
stageId,
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
message: 'Completed'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage as failed
|
||||
*/
|
||||
public failStage(stageId: string, message?: string): void {
|
||||
this.updateStageProgress({
|
||||
stageId,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
message: message || 'Failed'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a specific stage
|
||||
*/
|
||||
private renderStage(stage: ProgressStage): void {
|
||||
const stagesContainer = this.container.querySelector('.llm-progress-stages') as HTMLElement;
|
||||
let stageElement = stagesContainer.querySelector(`[data-stage-id="${stage.id}"]`) as HTMLElement;
|
||||
|
||||
if (!stageElement) {
|
||||
stageElement = this.createStageElement(stage);
|
||||
stagesContainer.appendChild(stageElement);
|
||||
}
|
||||
|
||||
this.updateStageElement(stageElement, stage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new stage element
|
||||
*/
|
||||
private createStageElement(stage: ProgressStage): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'llm-progress-stage';
|
||||
element.setAttribute('data-stage-id', stage.id);
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="stage-header">
|
||||
<div class="stage-status-icon">
|
||||
<i class="fas fa-circle"></i>
|
||||
</div>
|
||||
<div class="stage-label">${stage.label}</div>
|
||||
<div class="stage-timing"></div>
|
||||
</div>
|
||||
<div class="stage-progress">
|
||||
<div class="stage-progress-bar">
|
||||
<div class="stage-progress-fill"></div>
|
||||
</div>
|
||||
<div class="stage-progress-text">0%</div>
|
||||
</div>
|
||||
<div class="stage-message"></div>
|
||||
`;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stage element with current data
|
||||
*/
|
||||
private updateStageElement(element: HTMLElement, stage: ProgressStage): void {
|
||||
// Update status icon
|
||||
const icon = element.querySelector('.stage-status-icon i') as HTMLElement;
|
||||
icon.className = this.getStatusIcon(stage.status);
|
||||
|
||||
// Update progress bar
|
||||
const progressFill = element.querySelector('.stage-progress-fill') as HTMLElement;
|
||||
progressFill.style.width = `${stage.progress}%`;
|
||||
|
||||
// Update progress text
|
||||
const progressText = element.querySelector('.stage-progress-text') as HTMLElement;
|
||||
progressText.textContent = `${Math.round(stage.progress)}%`;
|
||||
|
||||
// Update message
|
||||
const messageElement = element.querySelector('.stage-message') as HTMLElement;
|
||||
messageElement.textContent = stage.message || '';
|
||||
messageElement.style.display = stage.message ? 'block' : 'none';
|
||||
|
||||
// Update timing
|
||||
const timingElement = element.querySelector('.stage-timing') as HTMLElement;
|
||||
timingElement.textContent = this.getStageTimingText(stage);
|
||||
|
||||
// Update stage status class
|
||||
element.className = `llm-progress-stage stage-${stage.status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon for stage
|
||||
*/
|
||||
private getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'pending': return 'fas fa-circle text-muted';
|
||||
case 'running': return 'fas fa-spinner fa-spin text-primary';
|
||||
case 'completed': return 'fas fa-check-circle text-success';
|
||||
case 'failed': return 'fas fa-exclamation-circle text-danger';
|
||||
default: return 'fas fa-circle';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timing text for stage
|
||||
*/
|
||||
private getStageTimingText(stage: ProgressStage): string {
|
||||
if (stage.endTime && stage.startTime) {
|
||||
const duration = Math.round((stage.endTime - stage.startTime) / 1000);
|
||||
return `${duration}s`;
|
||||
} else if (stage.startTime) {
|
||||
const elapsed = Math.round((Date.now() - stage.startTime) / 1000);
|
||||
return `${elapsed}s`;
|
||||
} else if (stage.estimatedDuration) {
|
||||
return `~${stage.estimatedDuration / 1000}s`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update overall progress
|
||||
*/
|
||||
private updateOverallProgress(): void {
|
||||
if (this.stages.size === 0) {
|
||||
this.overallProgress = 0;
|
||||
} else {
|
||||
const totalProgress = Array.from(this.stages.values())
|
||||
.reduce((sum, stage) => sum + stage.progress, 0);
|
||||
this.overallProgress = totalProgress / this.stages.size;
|
||||
}
|
||||
|
||||
// Update overall progress bar
|
||||
const overallFill = this.container.querySelector('.llm-progress-bar-fill') as HTMLElement;
|
||||
overallFill.style.width = `${this.overallProgress}%`;
|
||||
|
||||
// Update percentage text
|
||||
const percentageText = this.container.querySelector('.llm-progress-percentage') as HTMLElement;
|
||||
percentageText.textContent = `${Math.round(this.overallProgress)}%`;
|
||||
|
||||
// Update title based on progress
|
||||
const titleElement = this.container.querySelector('.llm-progress-title') as HTMLElement;
|
||||
if (this.overallProgress >= 100) {
|
||||
titleElement.textContent = 'Completed';
|
||||
} else if (this.overallProgress > 0) {
|
||||
titleElement.textContent = 'Processing...';
|
||||
} else {
|
||||
titleElement.textContent = 'Starting...';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update estimated remaining time
|
||||
*/
|
||||
private updateEstimatedTime(seconds: number): void {
|
||||
const estimatedElement = this.container.querySelector('.estimated-remaining') as HTMLElement;
|
||||
if (seconds > 0) {
|
||||
estimatedElement.textContent = `Est. remaining: ${this.formatTime(seconds)}`;
|
||||
} else {
|
||||
estimatedElement.textContent = 'Est. remaining: --';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time in seconds to readable format
|
||||
*/
|
||||
private formatTime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start elapsed time timer
|
||||
*/
|
||||
private elapsedTimer?: number;
|
||||
private startTime: number = Date.now();
|
||||
|
||||
private startElapsedTimer(): void {
|
||||
this.startTime = Date.now();
|
||||
this.elapsedTimer = window.setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - this.startTime) / 1000);
|
||||
const elapsedElement = this.container.querySelector('.elapsed-time') as HTMLElement;
|
||||
elapsedElement.textContent = `Elapsed: ${this.formatTime(elapsed)}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop elapsed time timer
|
||||
*/
|
||||
private stopElapsedTimer(): void {
|
||||
if (this.elapsedTimer) {
|
||||
clearInterval(this.elapsedTimer);
|
||||
this.elapsedTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stages and reset
|
||||
*/
|
||||
public reset(): void {
|
||||
this.stages.clear();
|
||||
const stagesContainer = this.container.querySelector('.llm-progress-stages') as HTMLElement;
|
||||
stagesContainer.innerHTML = '';
|
||||
this.overallProgress = 0;
|
||||
this.updateOverallProgress();
|
||||
this.stopElapsedTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cancel callback
|
||||
*/
|
||||
public onCancel(callback: () => void): void {
|
||||
const cancelBtn = this.container.querySelector('.llm-progress-cancel-btn') as HTMLElement;
|
||||
cancelBtn.onclick = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable cancel button
|
||||
*/
|
||||
public disableCancel(): void {
|
||||
const cancelBtn = this.container.querySelector('.llm-progress-cancel-btn') as HTMLButtonElement;
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.style.opacity = '0.5';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable cancel button
|
||||
*/
|
||||
public enableCancel(): void {
|
||||
const cancelBtn = this.container.querySelector('.llm-progress-cancel-btn') as HTMLButtonElement;
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { ProgressStage, ProgressUpdate };
|
||||
333
apps/client/src/widgets/llm_chat/tool_enhanced_ui.css
Normal file
333
apps/client/src/widgets/llm_chat/tool_enhanced_ui.css
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Enhanced Tool UI Styles
|
||||
* Styles for tool preview, feedback, and error recovery UI components
|
||||
*/
|
||||
|
||||
/* Tool Preview Styles */
|
||||
.tool-preview-container {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tool-preview-container.fade-out {
|
||||
animation: fadeOut 0.3s ease-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tool-preview-header {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-preview-item {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-preview-item:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tool-preview-item input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-preview-item .parameter-item {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tool-preview-item .parameter-key {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-preview-item details summary {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-preview-item details summary:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tool-preview-actions button {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Tool Feedback Styles */
|
||||
.tool-execution-feedback {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-feedback.fade-out {
|
||||
animation: fadeOut 0.3s ease-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tool-execution-feedback.border-success {
|
||||
border-color: var(--bs-success) !important;
|
||||
background-color: rgba(25, 135, 84, 0.05) !important;
|
||||
}
|
||||
|
||||
.tool-execution-feedback.border-danger {
|
||||
border-color: var(--bs-danger) !important;
|
||||
background-color: rgba(220, 53, 69, 0.05) !important;
|
||||
}
|
||||
|
||||
.tool-execution-feedback.border-warning {
|
||||
border-color: var(--bs-warning) !important;
|
||||
background-color: rgba(255, 193, 7, 0.05) !important;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .progress {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tool-execution-feedback .progress-bar {
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-steps {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-top: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-step {
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-step.tool-step-error {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-step.tool-step-warning {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.tool-execution-feedback .tool-step.tool-step-progress {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
}
|
||||
|
||||
.tool-execution-feedback .cancel-btn {
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-execution-feedback .cancel-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Real-time Progress Indicator */
|
||||
.tool-progress-realtime {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-progress-realtime::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Tool Execution History */
|
||||
.tool-history-container {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tool-history-container .history-item {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tool-history-container .history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Tool Statistics */
|
||||
.tool-stats-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tool-stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tool-stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.tool-stat-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--bs-secondary);
|
||||
}
|
||||
|
||||
/* Error Recovery UI */
|
||||
.tool-error-recovery {
|
||||
background-color: rgba(220, 53, 69, 0.05);
|
||||
border: 1px solid var(--bs-danger);
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tool-error-recovery .error-message {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-error-recovery .error-suggestions {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tool-error-recovery .error-suggestions li {
|
||||
padding: 0.25rem 0;
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-error-recovery .error-suggestions li::before {
|
||||
content: '→';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
.tool-recovery-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tool-recovery-actions button {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Circuit Breaker Indicator */
|
||||
.circuit-breaker-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.circuit-breaker-status.status-closed {
|
||||
background-color: rgba(25, 135, 84, 0.1);
|
||||
color: var(--bs-success);
|
||||
}
|
||||
|
||||
.circuit-breaker-status.status-open {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
|
||||
.circuit-breaker-status.status-half-open {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Spinner Override for Tool Execution */
|
||||
.tool-execution-feedback .spinner-border-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-width: 0.15em;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.tool-preview-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-preview-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tool-preview-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-stats-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tool-preview-container,
|
||||
.tool-execution-feedback {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.tool-preview-item {
|
||||
background-color: rgba(255, 255, 255, 0.03) !important;
|
||||
}
|
||||
|
||||
.tool-history-container,
|
||||
.tool-stats-container {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.parameter-item {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
309
apps/client/src/widgets/llm_chat/tool_execution_ui.ts
Normal file
309
apps/client/src/widgets/llm_chat/tool_execution_ui.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Tool Execution UI Components
|
||||
*
|
||||
* This module provides enhanced UI components for displaying tool execution status,
|
||||
* progress, and user-friendly error messages during LLM tool calls.
|
||||
*/
|
||||
|
||||
import { t } from "../../services/i18n.js";
|
||||
|
||||
/**
|
||||
* Tool execution status types
|
||||
*/
|
||||
export type ToolExecutionStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Tool execution display data
|
||||
*/
|
||||
export interface ToolExecutionDisplay {
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
status: ToolExecutionStatus;
|
||||
description?: string;
|
||||
progress?: {
|
||||
current: number;
|
||||
total: number;
|
||||
message?: string;
|
||||
};
|
||||
result?: string;
|
||||
error?: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of tool names to user-friendly display names
|
||||
*/
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
'search_notes': 'Searching Notes',
|
||||
'get_note_content': 'Reading Note',
|
||||
'create_note': 'Creating Note',
|
||||
'update_note': 'Updating Note',
|
||||
'execute_code': 'Running Code',
|
||||
'web_search': 'Searching Web',
|
||||
'get_note_attributes': 'Reading Note Properties',
|
||||
'set_note_attribute': 'Setting Note Property',
|
||||
'navigate_notes': 'Navigating Notes',
|
||||
'query_decomposition': 'Analyzing Query',
|
||||
'contextual_thinking': 'Processing Context'
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of tool names to descriptions
|
||||
*/
|
||||
const TOOL_DESCRIPTIONS: Record<string, string> = {
|
||||
'search_notes': 'Finding relevant notes in your knowledge base',
|
||||
'get_note_content': 'Reading the content of a specific note',
|
||||
'create_note': 'Creating a new note with the provided content',
|
||||
'update_note': 'Updating an existing note',
|
||||
'execute_code': 'Running code in a safe environment',
|
||||
'web_search': 'Searching the web for current information',
|
||||
'get_note_attributes': 'Reading note metadata and properties',
|
||||
'set_note_attribute': 'Updating note metadata',
|
||||
'navigate_notes': 'Exploring the note hierarchy',
|
||||
'query_decomposition': 'Breaking down complex queries',
|
||||
'contextual_thinking': 'Analyzing context for better understanding'
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a tool execution indicator element
|
||||
*/
|
||||
export function createToolExecutionIndicator(toolName: string): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'tool-execution-indicator mb-2 p-2 border rounded bg-light';
|
||||
container.dataset.toolName = toolName;
|
||||
|
||||
const displayName = TOOL_DISPLAY_NAMES[toolName] || toolName;
|
||||
const description = TOOL_DESCRIPTIONS[toolName] || '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="tool-status-icon me-2">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="tool-name fw-bold small">${displayName}</div>
|
||||
${description ? `<div class="tool-description text-muted small">${description}</div>` : ''}
|
||||
<div class="tool-progress" style="display: none;">
|
||||
<div class="progress mt-1" style="height: 4px;">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="progress-message text-muted small mt-1"></div>
|
||||
</div>
|
||||
<div class="tool-result text-success small mt-1" style="display: none;"></div>
|
||||
<div class="tool-error text-danger small mt-1" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="tool-duration text-muted small ms-2" style="display: none;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tool execution status
|
||||
*/
|
||||
export function updateToolExecutionStatus(
|
||||
container: HTMLElement,
|
||||
status: ToolExecutionStatus,
|
||||
data?: {
|
||||
progress?: { current: number; total: number; message?: string };
|
||||
result?: string;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
}
|
||||
): void {
|
||||
const statusIcon = container.querySelector('.tool-status-icon');
|
||||
const progressDiv = container.querySelector('.tool-progress') as HTMLElement;
|
||||
const progressBar = container.querySelector('.progress-bar') as HTMLElement;
|
||||
const progressMessage = container.querySelector('.progress-message') as HTMLElement;
|
||||
const resultDiv = container.querySelector('.tool-result') as HTMLElement;
|
||||
const errorDiv = container.querySelector('.tool-error') as HTMLElement;
|
||||
const durationDiv = container.querySelector('.tool-duration') as HTMLElement;
|
||||
|
||||
if (!statusIcon) return;
|
||||
|
||||
// Update status icon
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
statusIcon.innerHTML = `
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status">
|
||||
<span class="visually-hidden">Pending...</span>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'running':
|
||||
statusIcon.innerHTML = `
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Running...</span>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'success':
|
||||
statusIcon.innerHTML = '<i class="bx bx-check-circle text-success fs-5"></i>';
|
||||
container.classList.add('border-success', 'bg-success-subtle');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
statusIcon.innerHTML = '<i class="bx bx-error-circle text-danger fs-5"></i>';
|
||||
container.classList.add('border-danger', 'bg-danger-subtle');
|
||||
break;
|
||||
|
||||
case 'cancelled':
|
||||
statusIcon.innerHTML = '<i class="bx bx-x-circle text-warning fs-5"></i>';
|
||||
container.classList.add('border-warning', 'bg-warning-subtle');
|
||||
break;
|
||||
}
|
||||
|
||||
// Update progress if provided
|
||||
if (data?.progress && progressDiv && progressBar && progressMessage) {
|
||||
progressDiv.style.display = 'block';
|
||||
const percentage = (data.progress.current / data.progress.total) * 100;
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
if (data.progress.message) {
|
||||
progressMessage.textContent = data.progress.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Update result if provided
|
||||
if (data?.result && resultDiv) {
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.textContent = data.result;
|
||||
}
|
||||
|
||||
// Update error if provided
|
||||
if (data?.error && errorDiv) {
|
||||
errorDiv.style.display = 'block';
|
||||
errorDiv.textContent = formatErrorMessage(data.error);
|
||||
}
|
||||
|
||||
// Update duration if provided
|
||||
if (data?.duration && durationDiv) {
|
||||
durationDiv.style.display = 'block';
|
||||
durationDiv.textContent = formatDuration(data.duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error messages to be user-friendly
|
||||
*/
|
||||
function formatErrorMessage(error: string): string {
|
||||
// Remove technical details and provide user-friendly messages
|
||||
const errorMappings: Record<string, string> = {
|
||||
'ECONNREFUSED': 'Connection refused. Please check if the service is running.',
|
||||
'ETIMEDOUT': 'Request timed out. Please try again.',
|
||||
'ENOTFOUND': 'Service not found. Please check your configuration.',
|
||||
'401': 'Authentication failed. Please check your API credentials.',
|
||||
'403': 'Access denied. Please check your permissions.',
|
||||
'404': 'Resource not found.',
|
||||
'429': 'Rate limit exceeded. Please wait a moment and try again.',
|
||||
'500': 'Server error. Please try again later.',
|
||||
'503': 'Service temporarily unavailable. Please try again later.'
|
||||
};
|
||||
|
||||
for (const [key, message] of Object.entries(errorMappings)) {
|
||||
if (error.includes(key)) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error formatting
|
||||
if (error.length > 100) {
|
||||
return error.substring(0, 100) + '...';
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in a human-readable way
|
||||
*/
|
||||
function formatDuration(milliseconds: number): string {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${(milliseconds / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool execution summary
|
||||
*/
|
||||
export function createToolExecutionSummary(executions: ToolExecutionDisplay[]): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'tool-execution-summary mt-2 p-2 border rounded bg-light small';
|
||||
|
||||
const successful = executions.filter(e => e.status === 'success').length;
|
||||
const failed = executions.filter(e => e.status === 'error').length;
|
||||
const total = executions.length;
|
||||
|
||||
const totalDuration = executions.reduce((sum, e) => {
|
||||
if (e.startTime && e.endTime) {
|
||||
return sum + (e.endTime - e.startTime);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<i class="bx bx-check-shield me-1"></i>
|
||||
<span class="fw-bold">Tools Executed:</span>
|
||||
<span class="badge bg-success ms-1">${successful} successful</span>
|
||||
${failed > 0 ? `<span class="badge bg-danger ms-1">${failed} failed</span>` : ''}
|
||||
<span class="badge bg-secondary ms-1">${total} total</span>
|
||||
</div>
|
||||
${totalDuration > 0 ? `
|
||||
<div class="text-muted">
|
||||
<i class="bx bx-time me-1"></i>
|
||||
${formatDuration(totalDuration)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a loading indicator with custom message
|
||||
*/
|
||||
export function createLoadingIndicator(message: string = 'Processing...'): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'loading-indicator-enhanced d-flex align-items-center p-2';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="spinner-grow spinner-grow-sm text-primary me-2" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<span class="loading-message">${message}</span>
|
||||
`;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update loading indicator message
|
||||
*/
|
||||
export function updateLoadingMessage(container: HTMLElement, message: string): void {
|
||||
const messageElement = container.querySelector('.loading-message');
|
||||
if (messageElement) {
|
||||
messageElement.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createToolExecutionIndicator,
|
||||
updateToolExecutionStatus,
|
||||
createToolExecutionSummary,
|
||||
createLoadingIndicator,
|
||||
updateLoadingMessage
|
||||
};
|
||||
599
apps/client/src/widgets/llm_chat/tool_feedback_ui.ts
Normal file
599
apps/client/src/widgets/llm_chat/tool_feedback_ui.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* Tool Feedback UI Component
|
||||
*
|
||||
* Provides real-time feedback UI during tool execution including
|
||||
* progress tracking, step visualization, and execution history.
|
||||
*/
|
||||
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { VirtualScrollManager, createVirtualScroll } from './virtual_scroll.js';
|
||||
|
||||
// UI Constants
|
||||
const UI_CONSTANTS = {
|
||||
HISTORY_MOVE_DELAY: 5000,
|
||||
STEP_COLLAPSE_DELAY: 1000,
|
||||
FADE_OUT_DURATION: 300,
|
||||
MAX_HISTORY_UI_SIZE: 50,
|
||||
MAX_VISIBLE_STEPS: 3,
|
||||
MAX_STRING_DISPLAY_LENGTH: 100,
|
||||
MAX_STEP_CONTAINER_HEIGHT: 150,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Tool execution status
|
||||
*/
|
||||
export type ToolExecutionStatus = 'pending' | 'running' | 'success' | 'error' | 'cancelled' | 'timeout';
|
||||
|
||||
/**
|
||||
* Tool execution progress data
|
||||
*/
|
||||
export interface ToolProgressData {
|
||||
executionId: string;
|
||||
current: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
message?: string;
|
||||
estimatedTimeRemaining?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool execution step data
|
||||
*/
|
||||
export interface ToolStepData {
|
||||
executionId: string;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
type: 'info' | 'warning' | 'error' | 'progress';
|
||||
data?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool execution tracker
|
||||
*/
|
||||
interface ExecutionTracker {
|
||||
id: string;
|
||||
toolName: string;
|
||||
element: HTMLElement;
|
||||
startTime: number;
|
||||
status: ToolExecutionStatus;
|
||||
steps: ToolStepData[];
|
||||
animationFrameId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Feedback UI Manager
|
||||
*/
|
||||
export class ToolFeedbackUI {
|
||||
private container: HTMLElement;
|
||||
private executions: Map<string, ExecutionTracker> = new Map();
|
||||
private historyContainer?: HTMLElement;
|
||||
private statsContainer?: HTMLElement;
|
||||
private virtualScroll?: VirtualScrollManager;
|
||||
private historyItems: any[] = [];
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking a tool execution
|
||||
*/
|
||||
public startExecution(
|
||||
executionId: string,
|
||||
toolName: string,
|
||||
displayName?: string
|
||||
): void {
|
||||
// Create execution element
|
||||
const element = this.createExecutionElement(executionId, toolName, displayName);
|
||||
this.container.appendChild(element);
|
||||
|
||||
// Create tracker
|
||||
const tracker: ExecutionTracker = {
|
||||
id: executionId,
|
||||
toolName,
|
||||
element,
|
||||
startTime: Date.now(),
|
||||
status: 'running',
|
||||
steps: []
|
||||
};
|
||||
|
||||
// Start elapsed time update with requestAnimationFrame
|
||||
this.startElapsedTimeAnimation(tracker);
|
||||
|
||||
this.executions.set(executionId, tracker);
|
||||
|
||||
// Auto-scroll to new execution
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update execution progress
|
||||
*/
|
||||
public updateProgress(data: ToolProgressData): void {
|
||||
const tracker = this.executions.get(data.executionId);
|
||||
if (!tracker) return;
|
||||
|
||||
const progressBar = tracker.element.querySelector('.progress-bar') as HTMLElement;
|
||||
const progressText = tracker.element.querySelector('.progress-text') as HTMLElement;
|
||||
const progressContainer = tracker.element.querySelector('.tool-progress') as HTMLElement;
|
||||
|
||||
if (progressContainer) {
|
||||
progressContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${data.percentage}%`;
|
||||
progressBar.setAttribute('aria-valuenow', String(data.percentage));
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
let text = `${data.current}/${data.total}`;
|
||||
if (data.message) {
|
||||
text += ` - ${data.message}`;
|
||||
}
|
||||
if (data.estimatedTimeRemaining) {
|
||||
text += ` (${this.formatDuration(data.estimatedTimeRemaining)} remaining)`;
|
||||
}
|
||||
progressText.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add execution step
|
||||
*/
|
||||
public addStep(data: ToolStepData): void {
|
||||
const tracker = this.executions.get(data.executionId);
|
||||
if (!tracker) return;
|
||||
|
||||
tracker.steps.push(data);
|
||||
|
||||
const stepsContainer = tracker.element.querySelector('.tool-steps') as HTMLElement;
|
||||
if (stepsContainer) {
|
||||
const stepElement = this.createStepElement(data);
|
||||
stepsContainer.appendChild(stepElement);
|
||||
|
||||
// Show steps container if hidden
|
||||
stepsContainer.style.display = 'block';
|
||||
|
||||
// Auto-scroll steps
|
||||
stepsContainer.scrollTop = stepsContainer.scrollHeight;
|
||||
}
|
||||
|
||||
// Update status indicator for warnings/errors
|
||||
if (data.type === 'warning' || data.type === 'error') {
|
||||
this.updateStatusIndicator(tracker, data.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete execution
|
||||
*/
|
||||
public completeExecution(
|
||||
executionId: string,
|
||||
status: 'success' | 'error' | 'cancelled' | 'timeout',
|
||||
result?: any,
|
||||
error?: string
|
||||
): void {
|
||||
const tracker = this.executions.get(executionId);
|
||||
if (!tracker) return;
|
||||
|
||||
tracker.status = status;
|
||||
|
||||
// Stop elapsed time update
|
||||
if (tracker.animationFrameId) {
|
||||
cancelAnimationFrame(tracker.animationFrameId);
|
||||
tracker.animationFrameId = undefined;
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.updateStatusIndicator(tracker, status);
|
||||
|
||||
const duration = Date.now() - tracker.startTime;
|
||||
const durationElement = tracker.element.querySelector('.tool-duration') as HTMLElement;
|
||||
if (durationElement) {
|
||||
durationElement.textContent = this.formatDuration(duration);
|
||||
}
|
||||
|
||||
// Show result or error
|
||||
if (status === 'success' && result) {
|
||||
const resultElement = tracker.element.querySelector('.tool-result') as HTMLElement;
|
||||
if (resultElement) {
|
||||
resultElement.style.display = 'block';
|
||||
resultElement.textContent = this.formatResult(result);
|
||||
}
|
||||
} else if ((status === 'error' || status === 'timeout') && error) {
|
||||
const errorElement = tracker.element.querySelector('.tool-error') as HTMLElement;
|
||||
if (errorElement) {
|
||||
errorElement.style.display = 'block';
|
||||
errorElement.textContent = error;
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse steps after completion
|
||||
setTimeout(() => {
|
||||
this.collapseStepsIfNeeded(tracker);
|
||||
}, UI_CONSTANTS.STEP_COLLAPSE_DELAY);
|
||||
|
||||
// Move to history after a delay
|
||||
setTimeout(() => {
|
||||
this.moveToHistory(tracker);
|
||||
}, UI_CONSTANTS.HISTORY_MOVE_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel execution
|
||||
*/
|
||||
public cancelExecution(executionId: string): void {
|
||||
this.completeExecution(executionId, 'cancelled', undefined, 'Cancelled by user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create execution element
|
||||
*/
|
||||
private createExecutionElement(
|
||||
executionId: string,
|
||||
toolName: string,
|
||||
displayName?: string
|
||||
): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'tool-execution-feedback mb-2 p-2 border rounded bg-light';
|
||||
element.dataset.executionId = executionId;
|
||||
|
||||
element.innerHTML = `
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="tool-status-icon me-2">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Running...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="tool-name fw-bold small">
|
||||
${displayName || toolName}
|
||||
</div>
|
||||
<div class="tool-actions">
|
||||
<button class="btn btn-sm btn-link p-0 cancel-btn" title="Cancel">
|
||||
<i class="bx bx-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-progress mt-1" style="display: none;">
|
||||
<div class="progress" style="height: 4px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: 0%"
|
||||
aria-valuenow="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-text text-muted small mt-1"></div>
|
||||
</div>
|
||||
<div class="tool-steps mt-2 small" style="display: none; max-height: ${UI_CONSTANTS.MAX_STEP_CONTAINER_HEIGHT}px; overflow-y: auto;">
|
||||
</div>
|
||||
<div class="tool-result text-success small mt-2" style="display: none;"></div>
|
||||
<div class="tool-error text-danger small mt-2" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="tool-duration text-muted small ms-2">
|
||||
<span class="elapsed-time">0s</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add cancel button listener
|
||||
const cancelBtn = element.querySelector('.cancel-btn') as HTMLButtonElement;
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
this.cancelExecution(executionId);
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create step element
|
||||
*/
|
||||
private createStepElement(step: ToolStepData): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = `tool-step tool-step-${step.type} text-${this.getStepColor(step.type)} mb-1`;
|
||||
|
||||
const timestamp = new Date(step.timestamp).toLocaleTimeString();
|
||||
|
||||
element.innerHTML = `
|
||||
<i class="bx ${this.getStepIcon(step.type)} me-1"></i>
|
||||
<span class="step-time text-muted">[${timestamp}]</span>
|
||||
<span class="step-message ms-1">${step.message}</span>
|
||||
`;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status indicator
|
||||
*/
|
||||
private updateStatusIndicator(tracker: ExecutionTracker, status: string): void {
|
||||
const statusIcon = tracker.element.querySelector('.tool-status-icon');
|
||||
if (!statusIcon) return;
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
'success': '<i class="bx bx-check-circle text-success fs-5"></i>',
|
||||
'error': '<i class="bx bx-error-circle text-danger fs-5"></i>',
|
||||
'warning': '<i class="bx bx-error text-warning fs-5"></i>',
|
||||
'cancelled': '<i class="bx bx-x-circle text-warning fs-5"></i>',
|
||||
'timeout': '<i class="bx bx-time-five text-danger fs-5"></i>'
|
||||
};
|
||||
|
||||
if (icons[status]) {
|
||||
statusIcon.innerHTML = icons[status];
|
||||
}
|
||||
|
||||
// Update container style
|
||||
const borderColors: Record<string, string> = {
|
||||
'success': 'border-success',
|
||||
'error': 'border-danger',
|
||||
'warning': 'border-warning',
|
||||
'cancelled': 'border-warning',
|
||||
'timeout': 'border-danger'
|
||||
};
|
||||
|
||||
if (borderColors[status]) {
|
||||
tracker.element.classList.add(borderColors[status]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start elapsed time animation with requestAnimationFrame
|
||||
*/
|
||||
private startElapsedTimeAnimation(tracker: ExecutionTracker): void {
|
||||
const updateTime = () => {
|
||||
if (this.executions.has(tracker.id)) {
|
||||
const elapsed = Date.now() - tracker.startTime;
|
||||
const elapsedElement = tracker.element.querySelector('.elapsed-time') as HTMLElement;
|
||||
if (elapsedElement) {
|
||||
elapsedElement.textContent = this.formatDuration(elapsed);
|
||||
}
|
||||
tracker.animationFrameId = requestAnimationFrame(updateTime);
|
||||
}
|
||||
};
|
||||
tracker.animationFrameId = requestAnimationFrame(updateTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move execution to history
|
||||
*/
|
||||
private moveToHistory(tracker: ExecutionTracker): void {
|
||||
// Remove from active executions
|
||||
this.executions.delete(tracker.id);
|
||||
|
||||
// Fade out and remove
|
||||
tracker.element.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
tracker.element.remove();
|
||||
}, UI_CONSTANTS.FADE_OUT_DURATION);
|
||||
|
||||
// Add to history
|
||||
this.addToHistory(tracker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tracker to history
|
||||
*/
|
||||
private addToHistory(tracker: ExecutionTracker): void {
|
||||
// Add to history items array
|
||||
this.historyItems.unshift(tracker);
|
||||
|
||||
// Limit history size
|
||||
if (this.historyItems.length > UI_CONSTANTS.MAX_HISTORY_UI_SIZE) {
|
||||
this.historyItems = this.historyItems.slice(0, UI_CONSTANTS.MAX_HISTORY_UI_SIZE);
|
||||
}
|
||||
|
||||
// Update display
|
||||
if (this.virtualScroll) {
|
||||
this.virtualScroll.updateTotalItems(this.historyItems.length);
|
||||
this.virtualScroll.refresh();
|
||||
} else if (this.historyContainer) {
|
||||
const historyItem = this.createHistoryItem(tracker);
|
||||
this.historyContainer.prepend(historyItem);
|
||||
|
||||
// Limit DOM elements
|
||||
const elements = this.historyContainer.querySelectorAll('.history-item');
|
||||
if (elements.length > UI_CONSTANTS.MAX_HISTORY_UI_SIZE) {
|
||||
elements[elements.length - 1].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create history item
|
||||
*/
|
||||
private createHistoryItem(tracker: ExecutionTracker): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'history-item small text-muted mb-1';
|
||||
|
||||
const duration = Date.now() - tracker.startTime;
|
||||
const statusIcon = this.getStatusIcon(tracker.status);
|
||||
const time = new Date(tracker.startTime).toLocaleTimeString();
|
||||
|
||||
element.innerHTML = `
|
||||
${statusIcon}
|
||||
<span class="ms-1">${tracker.toolName}</span>
|
||||
<span class="ms-1">(${this.formatDuration(duration)})</span>
|
||||
<span class="ms-1 text-muted">${time}</span>
|
||||
`;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get step color
|
||||
*/
|
||||
private getStepColor(type: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
'info': 'muted',
|
||||
'warning': 'warning',
|
||||
'error': 'danger',
|
||||
'progress': 'primary'
|
||||
};
|
||||
return colors[type] || 'muted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get step icon
|
||||
*/
|
||||
private getStepIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
'info': 'bx-info-circle',
|
||||
'warning': 'bx-error',
|
||||
'error': 'bx-error-circle',
|
||||
'progress': 'bx-loader-alt'
|
||||
};
|
||||
return icons[type] || 'bx-circle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon
|
||||
*/
|
||||
private getStatusIcon(status: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
'success': '<i class="bx bx-check-circle text-success"></i>',
|
||||
'error': '<i class="bx bx-error-circle text-danger"></i>',
|
||||
'cancelled': '<i class="bx bx-x-circle text-warning"></i>',
|
||||
'timeout': '<i class="bx bx-time-five text-danger"></i>',
|
||||
'running': '<i class="bx bx-loader-alt text-primary"></i>',
|
||||
'pending': '<i class="bx bx-time text-muted"></i>'
|
||||
};
|
||||
return icons[status] || '<i class="bx bx-circle text-muted"></i>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse steps if there are too many
|
||||
*/
|
||||
private collapseStepsIfNeeded(tracker: ExecutionTracker): void {
|
||||
const stepsContainer = tracker.element.querySelector('.tool-steps') as HTMLElement;
|
||||
if (stepsContainer && tracker.steps.length > UI_CONSTANTS.MAX_VISIBLE_STEPS) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'mt-2';
|
||||
details.innerHTML = `
|
||||
<summary class="text-muted small cursor-pointer">
|
||||
Show ${tracker.steps.length} execution steps
|
||||
</summary>
|
||||
`;
|
||||
details.appendChild(stepsContainer.cloneNode(true));
|
||||
stepsContainer.replaceWith(details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format result for display
|
||||
*/
|
||||
private formatResult(result: any): string {
|
||||
if (typeof result === 'string') {
|
||||
return this.truncateString(result);
|
||||
}
|
||||
const json = JSON.stringify(result);
|
||||
return this.truncateString(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate string for display
|
||||
*/
|
||||
private truncateString(str: string, maxLength: number = UI_CONSTANTS.MAX_STRING_DISPLAY_LENGTH): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
return `${str.substring(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration
|
||||
*/
|
||||
private formatDuration(milliseconds: number): string {
|
||||
if (milliseconds < 1000) {
|
||||
return `${Math.round(milliseconds)}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${(milliseconds / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set history container with virtual scrolling
|
||||
*/
|
||||
public setHistoryContainer(container: HTMLElement, useVirtualScroll: boolean = false): void {
|
||||
this.historyContainer = container;
|
||||
|
||||
if (useVirtualScroll && this.historyItems.length > 20) {
|
||||
this.initializeVirtualScroll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize virtual scrolling for history
|
||||
*/
|
||||
private initializeVirtualScroll(): void {
|
||||
if (!this.historyContainer) return;
|
||||
|
||||
this.virtualScroll = createVirtualScroll({
|
||||
container: this.historyContainer,
|
||||
itemHeight: 30, // Approximate height of history items
|
||||
totalItems: this.historyItems.length,
|
||||
overscan: 3,
|
||||
onRenderItem: (index) => {
|
||||
return this.renderHistoryItemAtIndex(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render history item at specific index
|
||||
*/
|
||||
private renderHistoryItemAtIndex(index: number): HTMLElement {
|
||||
const item = this.historyItems[index];
|
||||
if (!item) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'history-item-empty';
|
||||
return empty;
|
||||
}
|
||||
|
||||
return this.createHistoryItem(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set statistics container
|
||||
*/
|
||||
public setStatsContainer(container: HTMLElement): void {
|
||||
this.statsContainer = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all executions
|
||||
*/
|
||||
public clear(): void {
|
||||
this.executions.forEach(tracker => {
|
||||
if (tracker.animationFrameId) {
|
||||
cancelAnimationFrame(tracker.animationFrameId);
|
||||
}
|
||||
});
|
||||
this.executions.clear();
|
||||
this.container.innerHTML = '';
|
||||
this.historyItems = [];
|
||||
|
||||
if (this.virtualScroll) {
|
||||
this.virtualScroll.destroy();
|
||||
this.virtualScroll = undefined;
|
||||
}
|
||||
|
||||
if (this.historyContainer) {
|
||||
this.historyContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool feedback UI instance
|
||||
*/
|
||||
export function createToolFeedbackUI(container: HTMLElement): ToolFeedbackUI {
|
||||
return new ToolFeedbackUI(container);
|
||||
}
|
||||
367
apps/client/src/widgets/llm_chat/tool_preview_ui.ts
Normal file
367
apps/client/src/widgets/llm_chat/tool_preview_ui.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Tool Preview UI Component
|
||||
*
|
||||
* Provides UI for previewing tool executions before they run,
|
||||
* allowing users to approve, reject, or modify tool parameters.
|
||||
*/
|
||||
|
||||
import { t } from "../../services/i18n.js";
|
||||
|
||||
/**
|
||||
* Tool preview data from server
|
||||
*/
|
||||
export interface ToolPreviewData {
|
||||
id: string;
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
formattedParameters: string[];
|
||||
estimatedDuration: number;
|
||||
riskLevel: 'low' | 'medium' | 'high';
|
||||
requiresConfirmation: boolean;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution plan from server
|
||||
*/
|
||||
export interface ExecutionPlanData {
|
||||
id: string;
|
||||
tools: ToolPreviewData[];
|
||||
totalEstimatedDuration: number;
|
||||
requiresConfirmation: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User approval data
|
||||
*/
|
||||
export interface UserApproval {
|
||||
planId: string;
|
||||
approved: boolean;
|
||||
rejectedTools?: string[];
|
||||
modifiedParameters?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Preview UI Manager
|
||||
*/
|
||||
export class ToolPreviewUI {
|
||||
private container: HTMLElement;
|
||||
private currentPlan?: ExecutionPlanData;
|
||||
private onApprovalCallback?: (approval: UserApproval) => void;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show tool execution preview
|
||||
*/
|
||||
public async showPreview(
|
||||
plan: ExecutionPlanData,
|
||||
onApproval: (approval: UserApproval) => void
|
||||
): Promise<void> {
|
||||
this.currentPlan = plan;
|
||||
this.onApprovalCallback = onApproval;
|
||||
|
||||
const previewElement = this.createPreviewElement(plan);
|
||||
this.container.appendChild(previewElement);
|
||||
|
||||
// Auto-scroll to preview
|
||||
previewElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create preview element
|
||||
*/
|
||||
private createPreviewElement(plan: ExecutionPlanData): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'tool-preview-container mb-3 border rounded p-3 bg-light';
|
||||
element.dataset.planId = plan.id;
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'tool-preview-header mb-3';
|
||||
header.innerHTML = `
|
||||
<h6 class="mb-2">
|
||||
<i class="bx bx-shield-quarter me-2"></i>
|
||||
${t('Tool Execution Preview')}
|
||||
</h6>
|
||||
<p class="text-muted small mb-2">
|
||||
${plan.tools.length} ${plan.tools.length === 1 ? 'tool' : 'tools'} will be executed
|
||||
${plan.requiresConfirmation ? ' (confirmation required)' : ''}
|
||||
</p>
|
||||
<div class="d-flex align-items-center gap-3 small text-muted">
|
||||
<span>
|
||||
<i class="bx bx-time-five me-1"></i>
|
||||
Estimated time: ${this.formatDuration(plan.totalEstimatedDuration)}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
element.appendChild(header);
|
||||
|
||||
// Tool list
|
||||
const toolList = document.createElement('div');
|
||||
toolList.className = 'tool-preview-list mb-3';
|
||||
|
||||
plan.tools.forEach((tool, index) => {
|
||||
const toolElement = this.createToolPreviewItem(tool, index);
|
||||
toolList.appendChild(toolElement);
|
||||
});
|
||||
|
||||
element.appendChild(toolList);
|
||||
|
||||
// Actions
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'tool-preview-actions d-flex gap-2';
|
||||
|
||||
if (plan.requiresConfirmation) {
|
||||
actions.innerHTML = `
|
||||
<button class="btn btn-success btn-sm approve-all-btn">
|
||||
<i class="bx bx-check me-1"></i>
|
||||
Approve All
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm modify-btn">
|
||||
<i class="bx bx-edit me-1"></i>
|
||||
Modify
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm reject-all-btn">
|
||||
<i class="bx bx-x me-1"></i>
|
||||
Reject All
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add event listeners
|
||||
const approveBtn = actions.querySelector('.approve-all-btn') as HTMLButtonElement;
|
||||
const modifyBtn = actions.querySelector('.modify-btn') as HTMLButtonElement;
|
||||
const rejectBtn = actions.querySelector('.reject-all-btn') as HTMLButtonElement;
|
||||
|
||||
approveBtn?.addEventListener('click', () => this.handleApproveAll());
|
||||
modifyBtn?.addEventListener('click', () => this.handleModify());
|
||||
rejectBtn?.addEventListener('click', () => this.handleRejectAll());
|
||||
} else {
|
||||
// Auto-approve after showing preview
|
||||
setTimeout(() => {
|
||||
this.handleApproveAll();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
element.appendChild(actions);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tool preview item
|
||||
*/
|
||||
private createToolPreviewItem(tool: ToolPreviewData, index: number): HTMLElement {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tool-preview-item mb-2 p-2 border rounded bg-white';
|
||||
item.dataset.toolName = tool.toolName;
|
||||
|
||||
const riskBadge = this.getRiskBadge(tool.riskLevel);
|
||||
const riskIcon = this.getRiskIcon(tool.riskLevel);
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="tool-preview-checkbox me-2 pt-1">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
id="tool-${index}"
|
||||
checked
|
||||
${tool.requiresConfirmation ? '' : 'disabled'}>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<label class="tool-name fw-bold small mb-0" for="tool-${index}">
|
||||
${tool.displayName}
|
||||
</label>
|
||||
${riskBadge}
|
||||
${riskIcon}
|
||||
</div>
|
||||
<div class="tool-description text-muted small mb-2">
|
||||
${tool.description}
|
||||
</div>
|
||||
<div class="tool-parameters small">
|
||||
<details>
|
||||
<summary class="text-primary cursor-pointer">
|
||||
Parameters (${Object.keys(tool.parameters).length})
|
||||
</summary>
|
||||
<div class="mt-1 p-2 bg-light rounded">
|
||||
${this.formatParameters(tool.formattedParameters)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
${tool.warnings && tool.warnings.length > 0 ? `
|
||||
<div class="tool-warnings mt-2">
|
||||
${tool.warnings.map(w => `
|
||||
<div class="alert alert-warning py-1 px-2 mb-1 small">
|
||||
<i class="bx bx-error-circle me-1"></i>
|
||||
${w}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="tool-duration text-muted small ms-2">
|
||||
<i class="bx bx-time me-1"></i>
|
||||
~${this.formatDuration(tool.estimatedDuration)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level badge
|
||||
*/
|
||||
private getRiskBadge(riskLevel: 'low' | 'medium' | 'high'): string {
|
||||
const badges = {
|
||||
low: '<span class="badge bg-success ms-2">Low Risk</span>',
|
||||
medium: '<span class="badge bg-warning ms-2">Medium Risk</span>',
|
||||
high: '<span class="badge bg-danger ms-2">High Risk</span>'
|
||||
};
|
||||
return badges[riskLevel] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level icon
|
||||
*/
|
||||
private getRiskIcon(riskLevel: 'low' | 'medium' | 'high'): string {
|
||||
const icons = {
|
||||
low: '<i class="bx bx-shield-check text-success ms-2"></i>',
|
||||
medium: '<i class="bx bx-shield text-warning ms-2"></i>',
|
||||
high: '<i class="bx bx-shield-x text-danger ms-2"></i>'
|
||||
};
|
||||
return icons[riskLevel] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format parameters for display
|
||||
*/
|
||||
private formatParameters(parameters: string[]): string {
|
||||
return parameters.map(param => {
|
||||
const [key, ...valueParts] = param.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
return `
|
||||
<div class="parameter-item mb-1">
|
||||
<span class="parameter-key text-muted">${key}:</span>
|
||||
<span class="parameter-value ms-1">${this.escapeHtml(value)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle approve all
|
||||
*/
|
||||
private handleApproveAll(): void {
|
||||
if (!this.currentPlan || !this.onApprovalCallback) return;
|
||||
|
||||
const approval: UserApproval = {
|
||||
planId: this.currentPlan.id,
|
||||
approved: true
|
||||
};
|
||||
|
||||
this.onApprovalCallback(approval);
|
||||
this.hidePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle modify
|
||||
*/
|
||||
private handleModify(): void {
|
||||
if (!this.currentPlan) return;
|
||||
|
||||
// Get selected tools
|
||||
const checkboxes = this.container.querySelectorAll('.tool-preview-item input[type="checkbox"]');
|
||||
const rejectedTools: string[] = [];
|
||||
|
||||
checkboxes.forEach((checkbox: Element) => {
|
||||
const input = checkbox as HTMLInputElement;
|
||||
const toolItem = input.closest('.tool-preview-item') as HTMLElement;
|
||||
const toolName = toolItem?.dataset.toolName;
|
||||
|
||||
if (toolName && !input.checked) {
|
||||
rejectedTools.push(toolName);
|
||||
}
|
||||
});
|
||||
|
||||
const approval: UserApproval = {
|
||||
planId: this.currentPlan.id,
|
||||
approved: true,
|
||||
rejectedTools: rejectedTools.length > 0 ? rejectedTools : undefined
|
||||
};
|
||||
|
||||
if (this.onApprovalCallback) {
|
||||
this.onApprovalCallback(approval);
|
||||
}
|
||||
|
||||
this.hidePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reject all
|
||||
*/
|
||||
private handleRejectAll(): void {
|
||||
if (!this.currentPlan || !this.onApprovalCallback) return;
|
||||
|
||||
const approval: UserApproval = {
|
||||
planId: this.currentPlan.id,
|
||||
approved: false
|
||||
};
|
||||
|
||||
this.onApprovalCallback(approval);
|
||||
this.hidePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide preview
|
||||
*/
|
||||
private hidePreview(): void {
|
||||
const preview = this.container.querySelector('.tool-preview-container');
|
||||
if (preview) {
|
||||
// Add fade out animation
|
||||
preview.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
preview.remove();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
this.currentPlan = undefined;
|
||||
this.onApprovalCallback = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration
|
||||
*/
|
||||
private formatDuration(milliseconds: number): string {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${(milliseconds / 1000).toFixed(1)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML
|
||||
*/
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool preview UI instance
|
||||
*/
|
||||
export function createToolPreviewUI(container: HTMLElement): ToolPreviewUI {
|
||||
return new ToolPreviewUI(container);
|
||||
}
|
||||
419
apps/client/src/widgets/llm_chat/tool_websocket.ts
Normal file
419
apps/client/src/widgets/llm_chat/tool_websocket.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Tool WebSocket Manager
|
||||
*
|
||||
* Provides real-time WebSocket communication for tool execution updates.
|
||||
* Implements automatic reconnection, heartbeat, and message queuing.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* WebSocket message types
|
||||
*/
|
||||
export enum WSMessageType {
|
||||
// Tool execution events
|
||||
TOOL_START = 'tool:start',
|
||||
TOOL_PROGRESS = 'tool:progress',
|
||||
TOOL_STEP = 'tool:step',
|
||||
TOOL_COMPLETE = 'tool:complete',
|
||||
TOOL_ERROR = 'tool:error',
|
||||
TOOL_CANCELLED = 'tool:cancelled',
|
||||
|
||||
// Connection events
|
||||
HEARTBEAT = 'heartbeat',
|
||||
PING = 'ping',
|
||||
PONG = 'pong',
|
||||
|
||||
// Control events
|
||||
SUBSCRIBE = 'subscribe',
|
||||
UNSUBSCRIBE = 'unsubscribe',
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket message structure
|
||||
*/
|
||||
export interface WSMessage {
|
||||
id: string;
|
||||
type: WSMessageType;
|
||||
timestamp: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket configuration
|
||||
*/
|
||||
export interface WSConfig {
|
||||
url: string;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
heartbeatInterval?: number;
|
||||
messageTimeout?: number;
|
||||
autoReconnect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state
|
||||
*/
|
||||
export enum ConnectionState {
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
RECONNECTING = 'reconnecting',
|
||||
DISCONNECTED = 'disconnected',
|
||||
FAILED = 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool WebSocket Manager
|
||||
*/
|
||||
export class ToolWebSocketManager extends EventEmitter {
|
||||
private ws?: WebSocket;
|
||||
private config: Required<WSConfig>;
|
||||
private state: ConnectionState = ConnectionState.DISCONNECTED;
|
||||
private reconnectAttempts: number = 0;
|
||||
private reconnectTimer?: number;
|
||||
private heartbeatTimer?: number;
|
||||
private messageQueue: WSMessage[] = [];
|
||||
private subscriptions: Set<string> = new Set();
|
||||
private lastPingTime?: number;
|
||||
private lastPongTime?: number;
|
||||
|
||||
// Performance constants
|
||||
private static readonly DEFAULT_RECONNECT_INTERVAL = 3000;
|
||||
private static readonly DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
|
||||
private static readonly DEFAULT_HEARTBEAT_INTERVAL = 30000;
|
||||
private static readonly DEFAULT_MESSAGE_TIMEOUT = 5000;
|
||||
private static readonly MAX_QUEUE_SIZE = 100;
|
||||
|
||||
constructor(config: WSConfig) {
|
||||
super();
|
||||
|
||||
this.config = {
|
||||
url: config.url,
|
||||
reconnectInterval: config.reconnectInterval ?? ToolWebSocketManager.DEFAULT_RECONNECT_INTERVAL,
|
||||
maxReconnectAttempts: config.maxReconnectAttempts ?? ToolWebSocketManager.DEFAULT_MAX_RECONNECT_ATTEMPTS,
|
||||
heartbeatInterval: config.heartbeatInterval ?? ToolWebSocketManager.DEFAULT_HEARTBEAT_INTERVAL,
|
||||
messageTimeout: config.messageTimeout ?? ToolWebSocketManager.DEFAULT_MESSAGE_TIMEOUT,
|
||||
autoReconnect: config.autoReconnect ?? true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
public connect(): void {
|
||||
if (this.state === ConnectionState.CONNECTED || this.state === ConnectionState.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = ConnectionState.CONNECTING;
|
||||
this.emit('connecting');
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.config.url);
|
||||
this.setupEventHandlers();
|
||||
} catch (error) {
|
||||
this.handleConnectionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket event handlers
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.ws) return;
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.state = ConnectionState.CONNECTED;
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connected');
|
||||
|
||||
// Start heartbeat
|
||||
this.startHeartbeat();
|
||||
|
||||
// Re-subscribe to previous subscriptions
|
||||
this.resubscribe();
|
||||
|
||||
// Flush message queue
|
||||
this.flushMessageQueue();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WSMessage = JSON.parse(event.data);
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.emit('error', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.state = ConnectionState.DISCONNECTED;
|
||||
this.stopHeartbeat();
|
||||
this.emit('disconnected', event.code, event.reason);
|
||||
|
||||
if (this.config.autoReconnect && !event.wasClean) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message
|
||||
*/
|
||||
private handleMessage(message: WSMessage): void {
|
||||
// Handle control messages
|
||||
switch (message.type) {
|
||||
case WSMessageType.PONG:
|
||||
this.lastPongTime = Date.now();
|
||||
return;
|
||||
|
||||
case WSMessageType.HEARTBEAT:
|
||||
this.send({
|
||||
id: message.id,
|
||||
type: WSMessageType.PONG,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit message for subscribers
|
||||
this.emit('message', message);
|
||||
this.emit(message.type, message.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message
|
||||
*/
|
||||
public send(message: WSMessage): void {
|
||||
if (this.state === ConnectionState.CONNECTED && this.ws?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Failed to send WebSocket message:', error);
|
||||
this.queueMessage(message);
|
||||
}
|
||||
} else {
|
||||
this.queueMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a message for later sending
|
||||
*/
|
||||
private queueMessage(message: WSMessage): void {
|
||||
if (this.messageQueue.length >= ToolWebSocketManager.MAX_QUEUE_SIZE) {
|
||||
this.messageQueue.shift(); // Remove oldest message
|
||||
}
|
||||
this.messageQueue.push(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush message queue
|
||||
*/
|
||||
private flushMessageQueue(): void {
|
||||
while (this.messageQueue.length > 0 && this.state === ConnectionState.CONNECTED) {
|
||||
const message = this.messageQueue.shift();
|
||||
if (message) {
|
||||
this.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to tool execution updates
|
||||
*/
|
||||
public subscribe(executionId: string): void {
|
||||
this.subscriptions.add(executionId);
|
||||
|
||||
if (this.state === ConnectionState.CONNECTED) {
|
||||
this.send({
|
||||
id: this.generateMessageId(),
|
||||
type: WSMessageType.SUBSCRIBE,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { executionId }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from tool execution updates
|
||||
*/
|
||||
public unsubscribe(executionId: string): void {
|
||||
this.subscriptions.delete(executionId);
|
||||
|
||||
if (this.state === ConnectionState.CONNECTED) {
|
||||
this.send({
|
||||
id: this.generateMessageId(),
|
||||
type: WSMessageType.UNSUBSCRIBE,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { executionId }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-subscribe to all previous subscriptions
|
||||
*/
|
||||
private resubscribe(): void {
|
||||
this.subscriptions.forEach(executionId => {
|
||||
this.send({
|
||||
id: this.generateMessageId(),
|
||||
type: WSMessageType.SUBSCRIBE,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { executionId }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat mechanism
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
|
||||
this.heartbeatTimer = window.setInterval(() => {
|
||||
if (this.state === ConnectionState.CONNECTED) {
|
||||
// Check if last pong was received
|
||||
if (this.lastPingTime && this.lastPongTime) {
|
||||
const timeSinceLastPong = Date.now() - this.lastPongTime;
|
||||
if (timeSinceLastPong > this.config.heartbeatInterval * 2) {
|
||||
// Connection seems dead, reconnect
|
||||
this.reconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send ping
|
||||
this.lastPingTime = Date.now();
|
||||
this.send({
|
||||
id: this.generateMessageId(),
|
||||
type: WSMessageType.PING,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: null
|
||||
});
|
||||
}
|
||||
}, this.config.heartbeatInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat mechanism
|
||||
*/
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
||||
this.state = ConnectionState.FAILED;
|
||||
this.emit('failed', 'Max reconnection attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = ConnectionState.RECONNECTING;
|
||||
this.reconnectAttempts++;
|
||||
|
||||
const delay = Math.min(
|
||||
this.config.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
|
||||
30000 // Max 30 seconds
|
||||
);
|
||||
|
||||
this.emit('reconnecting', this.reconnectAttempts, delay);
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to server
|
||||
*/
|
||||
public reconnect(): void {
|
||||
this.disconnect(false);
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection error
|
||||
*/
|
||||
private handleConnectionError(error: any): void {
|
||||
console.error('WebSocket connection error:', error);
|
||||
this.state = ConnectionState.DISCONNECTED;
|
||||
this.emit('error', error);
|
||||
|
||||
if (this.config.autoReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server
|
||||
*/
|
||||
public disconnect(clearSubscriptions: boolean = true): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
|
||||
this.stopHeartbeat();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect');
|
||||
this.ws = undefined;
|
||||
}
|
||||
|
||||
if (clearSubscriptions) {
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
this.messageQueue = [];
|
||||
this.state = ConnectionState.DISCONNECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state
|
||||
*/
|
||||
public getState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.state === ConnectionState.CONNECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique message ID
|
||||
*/
|
||||
private generateMessageId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the WebSocket manager
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.disconnect(true);
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create WebSocket manager instance
|
||||
*/
|
||||
export function createToolWebSocket(config: WSConfig): ToolWebSocketManager {
|
||||
return new ToolWebSocketManager(config);
|
||||
}
|
||||
312
apps/client/src/widgets/llm_chat/virtual_scroll.ts
Normal file
312
apps/client/src/widgets/llm_chat/virtual_scroll.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Virtual Scrolling Component
|
||||
*
|
||||
* Provides efficient rendering of large lists by only rendering visible items.
|
||||
* Optimized for the tool execution history display.
|
||||
*/
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
container: HTMLElement;
|
||||
itemHeight: number;
|
||||
totalItems: number;
|
||||
renderBuffer?: number;
|
||||
overscan?: number;
|
||||
onRenderItem: (index: number) => HTMLElement;
|
||||
onScrollEnd?: () => void;
|
||||
}
|
||||
|
||||
export interface VirtualScrollItem {
|
||||
index: number;
|
||||
element: HTMLElement;
|
||||
top: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual Scroll Manager
|
||||
*/
|
||||
export class VirtualScrollManager {
|
||||
private container: HTMLElement;
|
||||
private viewport: HTMLElement;
|
||||
private content: HTMLElement;
|
||||
private itemHeight: number;
|
||||
private totalItems: number;
|
||||
private renderBuffer: number;
|
||||
private overscan: number;
|
||||
private onRenderItem: (index: number) => HTMLElement;
|
||||
private onScrollEnd?: () => void;
|
||||
|
||||
private visibleItems: Map<number, VirtualScrollItem> = new Map();
|
||||
private scrollRAF?: number;
|
||||
private lastScrollTop: number = 0;
|
||||
private scrollEndTimeout?: number;
|
||||
|
||||
// Performance optimization constants
|
||||
private static readonly DEFAULT_RENDER_BUFFER = 3;
|
||||
private static readonly DEFAULT_OVERSCAN = 2;
|
||||
private static readonly SCROLL_END_DELAY = 150;
|
||||
private static readonly RECYCLE_POOL_SIZE = 50;
|
||||
|
||||
// Element recycling pool
|
||||
private recyclePool: HTMLElement[] = [];
|
||||
|
||||
constructor(options: VirtualScrollOptions) {
|
||||
this.container = options.container;
|
||||
this.itemHeight = options.itemHeight;
|
||||
this.totalItems = options.totalItems;
|
||||
this.renderBuffer = options.renderBuffer ?? VirtualScrollManager.DEFAULT_RENDER_BUFFER;
|
||||
this.overscan = options.overscan ?? VirtualScrollManager.DEFAULT_OVERSCAN;
|
||||
this.onRenderItem = options.onRenderItem;
|
||||
this.onScrollEnd = options.onScrollEnd;
|
||||
|
||||
this.setupStructure();
|
||||
this.attachListeners();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup DOM structure for virtual scrolling
|
||||
*/
|
||||
private setupStructure(): void {
|
||||
// Create viewport (scrollable container)
|
||||
this.viewport = document.createElement('div');
|
||||
this.viewport.className = 'virtual-scroll-viewport';
|
||||
this.viewport.style.cssText = `
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
// Create content (holds actual items)
|
||||
this.content = document.createElement('div');
|
||||
this.content.className = 'virtual-scroll-content';
|
||||
this.content.style.cssText = `
|
||||
position: relative;
|
||||
height: ${this.totalItems * this.itemHeight}px;
|
||||
`;
|
||||
|
||||
this.viewport.appendChild(this.content);
|
||||
this.container.appendChild(this.viewport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach scroll listeners
|
||||
*/
|
||||
private attachListeners(): void {
|
||||
this.viewport.addEventListener('scroll', this.handleScroll.bind(this), { passive: true });
|
||||
|
||||
// Use ResizeObserver for dynamic container size changes
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.render();
|
||||
});
|
||||
resizeObserver.observe(this.viewport);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll events with requestAnimationFrame
|
||||
*/
|
||||
private handleScroll(): void {
|
||||
if (this.scrollRAF) {
|
||||
cancelAnimationFrame(this.scrollRAF);
|
||||
}
|
||||
|
||||
this.scrollRAF = requestAnimationFrame(() => {
|
||||
this.render();
|
||||
this.detectScrollEnd();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect when scrolling has ended
|
||||
*/
|
||||
private detectScrollEnd(): void {
|
||||
const scrollTop = this.viewport.scrollTop;
|
||||
|
||||
if (this.scrollEndTimeout) {
|
||||
clearTimeout(this.scrollEndTimeout);
|
||||
}
|
||||
|
||||
this.scrollEndTimeout = window.setTimeout(() => {
|
||||
if (scrollTop === this.lastScrollTop) {
|
||||
this.onScrollEnd?.();
|
||||
}
|
||||
this.lastScrollTop = scrollTop;
|
||||
}, VirtualScrollManager.SCROLL_END_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render visible items
|
||||
*/
|
||||
private render(): void {
|
||||
const scrollTop = this.viewport.scrollTop;
|
||||
const viewportHeight = this.viewport.clientHeight;
|
||||
|
||||
// Calculate visible range with overscan
|
||||
const startIndex = Math.max(0,
|
||||
Math.floor(scrollTop / this.itemHeight) - this.overscan
|
||||
);
|
||||
const endIndex = Math.min(this.totalItems - 1,
|
||||
Math.ceil((scrollTop + viewportHeight) / this.itemHeight) + this.overscan
|
||||
);
|
||||
|
||||
// Remove items that are no longer visible
|
||||
this.removeInvisibleItems(startIndex, endIndex);
|
||||
|
||||
// Add new visible items
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (!this.visibleItems.has(i)) {
|
||||
this.addItem(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove items outside visible range
|
||||
*/
|
||||
private removeInvisibleItems(startIndex: number, endIndex: number): void {
|
||||
const itemsToRemove: number[] = [];
|
||||
|
||||
this.visibleItems.forEach((item, index) => {
|
||||
if (index < startIndex - this.renderBuffer || index > endIndex + this.renderBuffer) {
|
||||
itemsToRemove.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
itemsToRemove.forEach(index => {
|
||||
const item = this.visibleItems.get(index);
|
||||
if (item) {
|
||||
this.recycleElement(item.element);
|
||||
this.visibleItems.delete(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single item to the visible list
|
||||
*/
|
||||
private addItem(index: number): void {
|
||||
const element = this.getOrCreateElement(index);
|
||||
const top = index * this.itemHeight;
|
||||
|
||||
element.style.cssText = `
|
||||
position: absolute;
|
||||
top: ${top}px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: ${this.itemHeight}px;
|
||||
`;
|
||||
|
||||
this.content.appendChild(element);
|
||||
|
||||
this.visibleItems.set(index, {
|
||||
index,
|
||||
element,
|
||||
top
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an element (with recycling)
|
||||
*/
|
||||
private getOrCreateElement(index: number): HTMLElement {
|
||||
let element = this.recyclePool.pop();
|
||||
|
||||
if (element) {
|
||||
// Clear previous content
|
||||
element.innerHTML = '';
|
||||
element.className = '';
|
||||
} else {
|
||||
element = document.createElement('div');
|
||||
}
|
||||
|
||||
// Render new content
|
||||
const content = this.onRenderItem(index);
|
||||
if (content !== element) {
|
||||
element.appendChild(content);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recycle an element for reuse
|
||||
*/
|
||||
private recycleElement(element: HTMLElement): void {
|
||||
element.remove();
|
||||
|
||||
if (this.recyclePool.length < VirtualScrollManager.RECYCLE_POOL_SIZE) {
|
||||
this.recyclePool.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update total items and re-render
|
||||
*/
|
||||
public updateTotalItems(totalItems: number): void {
|
||||
this.totalItems = totalItems;
|
||||
this.content.style.height = `${totalItems * this.itemHeight}px`;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a specific index
|
||||
*/
|
||||
public scrollToIndex(index: number, behavior: ScrollBehavior = 'smooth'): void {
|
||||
const top = index * this.itemHeight;
|
||||
this.viewport.scrollTo({
|
||||
top,
|
||||
behavior
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current scroll position
|
||||
*/
|
||||
public getScrollPosition(): { index: number; offset: number } {
|
||||
const scrollTop = this.viewport.scrollTop;
|
||||
const index = Math.floor(scrollTop / this.itemHeight);
|
||||
const offset = scrollTop % this.itemHeight;
|
||||
|
||||
return { index, offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh visible items
|
||||
*/
|
||||
public refresh(): void {
|
||||
this.visibleItems.forEach(item => {
|
||||
item.element.remove();
|
||||
});
|
||||
this.visibleItems.clear();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the virtual scroll manager
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.scrollRAF) {
|
||||
cancelAnimationFrame(this.scrollRAF);
|
||||
}
|
||||
|
||||
if (this.scrollEndTimeout) {
|
||||
clearTimeout(this.scrollEndTimeout);
|
||||
}
|
||||
|
||||
this.visibleItems.forEach(item => {
|
||||
item.element.remove();
|
||||
});
|
||||
|
||||
this.visibleItems.clear();
|
||||
this.recyclePool = [];
|
||||
this.viewport.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a virtual scroll instance
|
||||
*/
|
||||
export function createVirtualScroll(options: VirtualScrollOptions): VirtualScrollManager {
|
||||
return new VirtualScrollManager(options);
|
||||
}
|
||||
@@ -727,9 +727,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
for (const key in hotKeys) {
|
||||
const handler = hotKeys[key];
|
||||
|
||||
$(this.tree.$container).on("keydown", null, key, (evt) => {
|
||||
shortcutService.bindElShortcut($(this.tree.$container), key, () => {
|
||||
const node = this.tree.getActiveNode();
|
||||
return handler(node, evt);
|
||||
return handler(node, {} as JQuery.KeyDownEvent);
|
||||
// return false from the handler will stop default handling.
|
||||
});
|
||||
}
|
||||
@@ -1552,7 +1552,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
const hotKeyMap: Record<string, (node: Fancytree.FancytreeNode, e: JQuery.KeyDownEvent) => boolean> = {};
|
||||
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||
hotKeyMap[shortcutService.normalizeShortcut(shortcut)] = (node) => {
|
||||
const notePath = treeService.getNotePath(node);
|
||||
|
||||
|
||||
41
apps/client/src/widgets/react/Modal.tsx
Normal file
41
apps/client/src/widgets/react/Modal.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import { ComponentChildren } from "preact";
|
||||
|
||||
interface ModalProps {
|
||||
className: string;
|
||||
title: string;
|
||||
size: "lg" | "sm";
|
||||
children: ComponentChildren;
|
||||
onShown?: () => void;
|
||||
}
|
||||
|
||||
export default function Modal({ children, className, size, title, onShown }: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (onShown) {
|
||||
useEffect(() => {
|
||||
const modalElement = modalRef.current;
|
||||
if (modalElement) {
|
||||
modalElement.addEventListener("shown.bs.modal", onShown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`modal fade mx-auto ${className}`} tabIndex={-1} role="dialog" ref={modalRef}>
|
||||
<div className={`modal-dialog modal-${size}`} role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{title}</h5>
|
||||
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")}></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
apps/client/src/widgets/react/ReactBasicWidget.ts
Normal file
14
apps/client/src/widgets/react/ReactBasicWidget.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { JSX, render } from "preact";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
|
||||
export default abstract class ReactBasicWidget extends BasicWidget {
|
||||
|
||||
abstract get component(): JSX.Element;
|
||||
|
||||
doRender() {
|
||||
const renderContainer = new DocumentFragment();
|
||||
render(this.component, renderContainer);
|
||||
this.$widget = $(renderContainer.firstChild as HTMLElement);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,16 @@ import type FNote from "../../entities/fnote.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { bookPropertiesConfig, BookProperty } from "./book_properties_config.js";
|
||||
import attributes from "../../services/attributes.js";
|
||||
import type { ViewTypeOptions } from "../../services/note_list_renderer.js";
|
||||
|
||||
const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
|
||||
grid: t("book_properties.grid"),
|
||||
list: t("book_properties.list"),
|
||||
calendar: t("book_properties.calendar"),
|
||||
table: t("book_properties.table"),
|
||||
geoMap: t("book_properties.geo-map"),
|
||||
board: t("book_properties.board")
|
||||
};
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="book-properties-widget">
|
||||
@@ -35,17 +45,25 @@ const TPL = /*html*/`
|
||||
.book-properties-container input[type="checkbox"] {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.book-properties-container label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-overflow: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div style="display: flex; align-items: baseline">
|
||||
<span style="white-space: nowrap">${t("book_properties.view_type")}: </span>
|
||||
|
||||
<select class="view-type-select form-select form-select-sm">
|
||||
<option value="grid">${t("book_properties.grid")}</option>
|
||||
<option value="list">${t("book_properties.list")}</option>
|
||||
<option value="calendar">${t("book_properties.calendar")}</option>
|
||||
<option value="table">${t("book_properties.table")}</option>
|
||||
<option value="geoMap">${t("book_properties.geo-map")}</option>
|
||||
${Object.entries(VIEW_TYPE_MAPPINGS)
|
||||
.filter(([type]) => type !== "raster")
|
||||
.map(([type, label]) => `
|
||||
<option value="${type}">${label}</option>
|
||||
`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +133,7 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["list", "grid", "calendar", "table", "geoMap"].includes(type)) {
|
||||
if (!VIEW_TYPE_MAPPINGS.hasOwnProperty(type)) {
|
||||
throw new Error(t("book_properties.invalid_view_type", { type }));
|
||||
}
|
||||
|
||||
@@ -195,6 +213,35 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
|
||||
.append(" ".repeat(2))
|
||||
.append($numberInput));
|
||||
break;
|
||||
case "combobox":
|
||||
const $select = $("<select>", {
|
||||
class: "form-select form-select-sm"
|
||||
});
|
||||
const actualValue = note.getLabelValue(property.bindToLabel) ?? property.defaultValue ?? "";
|
||||
for (const option of property.options) {
|
||||
if ("items" in option) {
|
||||
const $optGroup = $("<optgroup>", { label: option.name });
|
||||
for (const item of option.items) {
|
||||
buildComboBoxItem(item, actualValue).appendTo($optGroup);
|
||||
}
|
||||
$optGroup.appendTo($select);
|
||||
} else {
|
||||
buildComboBoxItem(option, actualValue).appendTo($select);
|
||||
}
|
||||
}
|
||||
$select.on("change", () => {
|
||||
const value = $select.val();
|
||||
if (value === null || value === "") {
|
||||
attributes.removeOwnedLabelByName(note, property.bindToLabel);
|
||||
} else {
|
||||
attributes.setLabel(note.noteId, property.bindToLabel, String(value));
|
||||
}
|
||||
});
|
||||
$container.append($("<label>")
|
||||
.text(property.label)
|
||||
.append(" ".repeat(2))
|
||||
.append($select));
|
||||
break;
|
||||
}
|
||||
|
||||
return $container;
|
||||
@@ -202,3 +249,14 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
|
||||
|
||||
|
||||
}
|
||||
|
||||
function buildComboBoxItem({ value, label }: { value: string, label: string }, actualValue: string) {
|
||||
const $option = $("<option>", {
|
||||
value,
|
||||
text: label
|
||||
});
|
||||
if (actualValue === value) {
|
||||
$option.prop("selected", true);
|
||||
}
|
||||
return $option;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { ViewTypeOptions } from "../../services/note_list_renderer"
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget";
|
||||
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, type MapLayer } from "../view_widgets/geo_view/map_layer";
|
||||
|
||||
interface BookConfig {
|
||||
properties: BookProperty[];
|
||||
@@ -30,7 +31,28 @@ interface NumberProperty {
|
||||
min?: number;
|
||||
}
|
||||
|
||||
export type BookProperty = CheckBoxProperty | ButtonProperty | NumberProperty;
|
||||
interface ComboBoxItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ComboBoxGroup {
|
||||
name: string;
|
||||
items: ComboBoxItem[];
|
||||
}
|
||||
|
||||
interface ComboBoxProperty {
|
||||
type: "combobox",
|
||||
label: string;
|
||||
bindToLabel: string;
|
||||
/**
|
||||
* The default value is used when the label is not set.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
options: (ComboBoxItem | ComboBoxGroup)[];
|
||||
}
|
||||
|
||||
export type BookProperty = CheckBoxProperty | ButtonProperty | NumberProperty | ComboBoxProperty;
|
||||
|
||||
interface BookContext {
|
||||
note: FNote;
|
||||
@@ -90,16 +112,58 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
|
||||
]
|
||||
},
|
||||
geoMap: {
|
||||
properties: []
|
||||
properties: [
|
||||
{
|
||||
label: t("book_properties_config.map-style"),
|
||||
type: "combobox",
|
||||
bindToLabel: "map:style",
|
||||
defaultValue: DEFAULT_MAP_LAYER_NAME,
|
||||
options: [
|
||||
{
|
||||
name: t("book_properties_config.raster"),
|
||||
items: Object.entries(MAP_LAYERS)
|
||||
.filter(([_, layer]) => layer.type === "raster")
|
||||
.map(buildMapLayer)
|
||||
},
|
||||
{
|
||||
name: t("book_properties_config.vector_light"),
|
||||
items: Object.entries(MAP_LAYERS)
|
||||
.filter(([_, layer]) => layer.type === "vector" && !layer.isDarkTheme)
|
||||
.map(buildMapLayer)
|
||||
},
|
||||
{
|
||||
name: t("book_properties_config.vector_dark"),
|
||||
items: Object.entries(MAP_LAYERS)
|
||||
.filter(([_, layer]) => layer.type === "vector" && layer.isDarkTheme)
|
||||
.map(buildMapLayer)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t("book_properties_config.show-scale"),
|
||||
type: "checkbox",
|
||||
bindToLabel: "map:scale"
|
||||
}
|
||||
]
|
||||
},
|
||||
table: {
|
||||
properties: [
|
||||
{
|
||||
label: "Max nesting depth:",
|
||||
label: t("book_properties_config.max-nesting-depth"),
|
||||
type: "number",
|
||||
bindToLabel: "maxNestingDepth",
|
||||
width: 65
|
||||
}
|
||||
]
|
||||
},
|
||||
board: {
|
||||
properties: []
|
||||
}
|
||||
};
|
||||
|
||||
function buildMapLayer([ id, layer ]: [ string, MapLayer ]): ComboBoxItem {
|
||||
return {
|
||||
value: id,
|
||||
label: layer.name
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,12 +53,56 @@ const TPL = /*html*/`
|
||||
word-break:keep-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
width: 22px !important;
|
||||
flex-grow: 0;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
/* Restore default apperance */
|
||||
.promoted-attribute-cell input[type="number"],
|
||||
.promoted-attribute-cell input[type="checkbox"] {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 2px;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
border-radius: 25% !important;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 0px;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
transform: rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="promoted-attributes-container"></div>
|
||||
@@ -258,6 +302,35 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
||||
.on("click", () => window.open($input.val() as string, "_blank"));
|
||||
|
||||
$input.after($openButton);
|
||||
} else if (definition.labelType === "color") {
|
||||
const defaultColor = "#ffffff";
|
||||
$input.prop("type", "hidden");
|
||||
$input.val(valueAttr.value ?? "");
|
||||
|
||||
// We insert a separate input since the color input does not support empty value.
|
||||
// This is a workaround to allow clearing the color input.
|
||||
const $colorInput = $("<input>")
|
||||
.prop("type", "color")
|
||||
.prop("value", valueAttr.value || defaultColor)
|
||||
.addClass("form-control promoted-attribute-input")
|
||||
.on("change", e => setValue((e.target as HTMLInputElement).value, e));
|
||||
$input.after($colorInput);
|
||||
|
||||
const $clearButton = $("<span>")
|
||||
.addClass("input-group-text bx bxs-tag-x")
|
||||
.prop("title", t("promoted_attributes.remove_color"))
|
||||
.on("click", e => setValue("", e));
|
||||
|
||||
const setValue = (color: string, event: JQuery.TriggeredEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => {
|
||||
$input.val(color);
|
||||
if (!color) {
|
||||
$colorInput.val(defaultColor);
|
||||
}
|
||||
event.target = $input[0]; // Set the event target to the main input
|
||||
this.promotedAttributeChanged(event);
|
||||
};
|
||||
|
||||
$colorInput.after($clearButton);
|
||||
} else {
|
||||
ws.logError(t("promoted_attributes.unknown_label_type", { type: definition.labelType }));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import appContext, { type CommandNames, type CommandListenerData, type EventData
|
||||
import froca from "../services/froca.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import type NoteContext from "../components/note_context.js";
|
||||
import { setupHorizontalScrollViaWheel } from "./widget_utils.js";
|
||||
|
||||
const isDesktop = utils.isDesktop();
|
||||
|
||||
@@ -386,15 +387,7 @@ export default class TabRowWidget extends BasicWidget {
|
||||
};
|
||||
|
||||
setupScrollEvents() {
|
||||
this.$tabScrollingContainer.on('wheel', (event) => {
|
||||
const wheelEvent = event.originalEvent as WheelEvent;
|
||||
if (utils.isCtrlKey(event) || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
event.currentTarget.scrollLeft += wheelEvent.deltaY + wheelEvent.deltaX;
|
||||
});
|
||||
setupHorizontalScrollViaWheel(this.$tabScrollingContainer);
|
||||
|
||||
this.$scrollButtonLeft[0].addEventListener('click', () => this.scrollTabContainer(-210));
|
||||
this.$scrollButtonRight[0].addEventListener('click', () => this.scrollTabContainer(210));
|
||||
|
||||
@@ -130,7 +130,8 @@ export default abstract class AbstractSplitTypeWidget extends TypeWidget {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.editorTypeWidget = new EditableCodeTypeWidget();
|
||||
|
||||
this.editorTypeWidget = new EditableCodeTypeWidget(true);
|
||||
this.editorTypeWidget.updateBackgroundColor = () => {};
|
||||
this.editorTypeWidget.isEnabled = () => true;
|
||||
|
||||
@@ -146,6 +147,8 @@ export default abstract class AbstractSplitTypeWidget extends TypeWidget {
|
||||
doRender(): void {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
this.spacedUpdate.setUpdateInterval(750);
|
||||
|
||||
// Preview pane
|
||||
this.$previewCol = this.$widget.find(".note-detail-split-preview-col");
|
||||
this.$preview = this.$widget.find(".note-detail-split-preview");
|
||||
|
||||
@@ -45,12 +45,11 @@ export default class BookTypeWidget extends TypeWidget {
|
||||
}
|
||||
|
||||
switch (this.note?.getAttributeValue("label", "viewType")) {
|
||||
case "calendar":
|
||||
case "table":
|
||||
case "geoMap":
|
||||
return false;
|
||||
default:
|
||||
case "list":
|
||||
case "grid":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,6 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
onChange: () => this.onChangeHandler(),
|
||||
viewModeEnabled: options.is("databaseReadonly"),
|
||||
zenModeEnabled: false,
|
||||
gridModeEnabled: false,
|
||||
isCollaborating: false,
|
||||
detectScroll: false,
|
||||
handleKeyboardGlobally: false,
|
||||
|
||||
@@ -153,7 +153,8 @@ export default class Canvas {
|
||||
appState: {
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
zoom: appState.zoom
|
||||
zoom: appState.zoom,
|
||||
gridModeEnabled: appState.gridModeEnabled
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,16 @@ const TPL = /*html*/`
|
||||
|
||||
export default class EditableCodeTypeWidget extends AbstractCodeTypeWidget {
|
||||
|
||||
private debounceUpdate: boolean;
|
||||
|
||||
/**
|
||||
* @param debounceUpdate if true, the update will be debounced to prevent excessive updates. Especially useful if the editor is linked to a live preview.
|
||||
*/
|
||||
constructor(debounceUpdate: boolean = false) {
|
||||
super();
|
||||
this.debounceUpdate = debounceUpdate;
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "editableCode";
|
||||
}
|
||||
@@ -46,7 +56,13 @@ export default class EditableCodeTypeWidget extends AbstractCodeTypeWidget {
|
||||
return {
|
||||
placeholder: t("editable_code.placeholder"),
|
||||
vimKeybindings: options.is("vimKeymapEnabled"),
|
||||
onContentChanged: () => this.spacedUpdate.scheduleUpdate(),
|
||||
onContentChanged: () => {
|
||||
if (this.debounceUpdate) {
|
||||
this.spacedUpdate.resetUpdateTimer();
|
||||
}
|
||||
|
||||
this.spacedUpdate.scheduleUpdate();
|
||||
},
|
||||
tabIndex: 300
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import utils from "../../../services/utils.js";
|
||||
import dialogService from "../../../services/dialog.js";
|
||||
import OptionsWidget from "./options_widget.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import type { OptionNames, KeyboardShortcut } from "@triliumnext/commons";
|
||||
import type { OptionNames, KeyboardShortcut, KeyboardShortcutWithRequiredActionName } from "@triliumnext/commons";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="options-section shortcuts-options-section tn-no-card">
|
||||
@@ -75,10 +75,10 @@ export default class KeyboardShortcutsOptions extends OptionsWidget {
|
||||
for (const action of actions) {
|
||||
const $tr = $("<tr>");
|
||||
|
||||
if (action.separator) {
|
||||
if ("separator" in action) {
|
||||
$tr.append($('<td class="separator" colspan="4">').attr("style", "background-color: var(--accented-background-color); font-weight: bold;").text(action.separator));
|
||||
} else if (action.defaultShortcuts && action.actionName) {
|
||||
$tr.append($("<td>").text(action.actionName))
|
||||
$tr.append($("<td>").text(action.friendlyName))
|
||||
.append(
|
||||
$("<td>").append(
|
||||
$(`<input type="text" class="form-control">`)
|
||||
@@ -145,9 +145,9 @@ export default class KeyboardShortcutsOptions extends OptionsWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = globActions.find((act) => act.actionName === actionName);
|
||||
const action = globActions.find((act) => "actionName" in act && act.actionName === actionName) as KeyboardShortcutWithRequiredActionName;
|
||||
|
||||
if (!action || !action.actionName) {
|
||||
if (!action) {
|
||||
this.$widget.find(el).hide();
|
||||
return;
|
||||
}
|
||||
@@ -157,6 +157,7 @@ export default class KeyboardShortcutsOptions extends OptionsWidget {
|
||||
.toggle(
|
||||
!!(
|
||||
action.actionName.toLowerCase().includes(filter) ||
|
||||
(action.friendlyName && action.friendlyName.toLowerCase().includes(filter)) ||
|
||||
(action.defaultShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) ||
|
||||
(action.effectiveShortcuts ?? []).some((shortcut) => shortcut.toLowerCase().includes(filter)) ||
|
||||
(action.description && action.description.toLowerCase().includes(filter))
|
||||
|
||||
@@ -52,7 +52,8 @@ export default class DateTimeFormatOptions extends OptionsWidget {
|
||||
}
|
||||
|
||||
async optionsLoaded(options: OptionMap) {
|
||||
const shortcutKey = (await keyboardActionsService.getAction("insertDateTimeToText")).effectiveShortcuts.join(", ");
|
||||
const action = await keyboardActionsService.getAction("insertDateTimeToText");
|
||||
const shortcutKey = (action.effectiveShortcuts ?? []).join(", ");
|
||||
const $link = await linkService.createLink("_hidden/_options/_optionsShortcuts", {
|
||||
"title": shortcutKey,
|
||||
"showTooltip": false
|
||||
|
||||
180
apps/client/src/widgets/view_widgets/board_view/api.ts
Normal file
180
apps/client/src/widgets/view_widgets/board_view/api.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import attributes from "../../../services/attributes";
|
||||
import { executeBulkActions } from "../../../services/bulk_action";
|
||||
import note_create from "../../../services/note_create";
|
||||
import ViewModeStorage from "../view_mode_storage";
|
||||
import { BoardData } from "./config";
|
||||
import { ColumnMap, getBoardData } from "./data";
|
||||
|
||||
export default class BoardApi {
|
||||
|
||||
private constructor(
|
||||
private _columns: string[],
|
||||
private _parentNoteId: string,
|
||||
private viewStorage: ViewModeStorage<BoardData>,
|
||||
private byColumn: ColumnMap,
|
||||
private persistedData: BoardData,
|
||||
private _statusAttribute: string) {}
|
||||
|
||||
get columns() {
|
||||
return this._columns;
|
||||
}
|
||||
|
||||
get statusAttribute() {
|
||||
return this._statusAttribute;
|
||||
}
|
||||
|
||||
getColumn(column: string) {
|
||||
return this.byColumn.get(column);
|
||||
}
|
||||
|
||||
async changeColumn(noteId: string, newColumn: string) {
|
||||
await attributes.setLabel(noteId, this._statusAttribute, newColumn);
|
||||
}
|
||||
|
||||
openNote(noteId: string) {
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId });
|
||||
}
|
||||
|
||||
async insertRowAtPosition(
|
||||
column: string,
|
||||
relativeToBranchId: string,
|
||||
direction: "before" | "after",
|
||||
open: boolean = true) {
|
||||
const { note } = await note_create.createNote(this._parentNoteId, {
|
||||
activate: false,
|
||||
targetBranchId: relativeToBranchId,
|
||||
target: direction,
|
||||
title: "New item"
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
throw new Error("Failed to create note");
|
||||
}
|
||||
|
||||
const { noteId } = note;
|
||||
await this.changeColumn(noteId, column);
|
||||
if (open) {
|
||||
this.openNote(noteId);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
async renameColumn(oldValue: string, newValue: string, noteIds: string[]) {
|
||||
// Change the value in the notes.
|
||||
await executeBulkActions(noteIds, [
|
||||
{
|
||||
name: "updateLabelValue",
|
||||
labelName: this._statusAttribute,
|
||||
labelValue: newValue
|
||||
}
|
||||
]);
|
||||
|
||||
// Rename the column in the persisted data.
|
||||
for (const column of this.persistedData.columns || []) {
|
||||
if (column.value === oldValue) {
|
||||
column.value = newValue;
|
||||
}
|
||||
}
|
||||
await this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
async removeColumn(column: string) {
|
||||
// Remove the value from the notes.
|
||||
const noteIds = this.byColumn.get(column)?.map(item => item.note.noteId) || [];
|
||||
await executeBulkActions(noteIds, [
|
||||
{
|
||||
name: "deleteLabel",
|
||||
labelName: this._statusAttribute
|
||||
}
|
||||
]);
|
||||
|
||||
this.persistedData.columns = (this.persistedData.columns ?? []).filter(col => col.value !== column);
|
||||
this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
async createColumn(columnValue: string) {
|
||||
// Add the new column to persisted data if it doesn't exist
|
||||
if (!this.persistedData.columns) {
|
||||
this.persistedData.columns = [];
|
||||
}
|
||||
|
||||
const existingColumn = this.persistedData.columns.find(col => col.value === columnValue);
|
||||
if (!existingColumn) {
|
||||
this.persistedData.columns.push({ value: columnValue });
|
||||
await this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
return columnValue;
|
||||
}
|
||||
|
||||
async reorderColumns(newColumnOrder: string[]) {
|
||||
// Update the column order in persisted data
|
||||
if (!this.persistedData.columns) {
|
||||
this.persistedData.columns = [];
|
||||
}
|
||||
|
||||
// Create a map of existing column data
|
||||
const columnDataMap = new Map();
|
||||
this.persistedData.columns.forEach(col => {
|
||||
columnDataMap.set(col.value, col);
|
||||
});
|
||||
|
||||
// Reorder columns based on new order
|
||||
this.persistedData.columns = newColumnOrder.map(columnValue => {
|
||||
return columnDataMap.get(columnValue) || { value: columnValue };
|
||||
});
|
||||
|
||||
// Update internal columns array
|
||||
this._columns = newColumnOrder;
|
||||
|
||||
await this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
async refresh(parentNote: FNote) {
|
||||
// Refresh the API data by re-fetching from the parent note
|
||||
const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status";
|
||||
this._statusAttribute = statusAttribute;
|
||||
|
||||
// Use the current in-memory persisted data instead of restoring from storage
|
||||
// This ensures we don't lose recent updates like column renames
|
||||
const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, this.persistedData);
|
||||
|
||||
// Update internal state
|
||||
this.byColumn = byColumn;
|
||||
|
||||
if (newPersistedData) {
|
||||
this.persistedData = newPersistedData;
|
||||
this.viewStorage.store(this.persistedData);
|
||||
}
|
||||
|
||||
// Use the order from persistedData.columns, then add any new columns found
|
||||
const orderedColumns = this.persistedData.columns?.map(col => col.value) || [];
|
||||
const allColumns = Array.from(byColumn.keys());
|
||||
const newColumns = allColumns.filter(col => !orderedColumns.includes(col));
|
||||
this._columns = [...orderedColumns, ...newColumns];
|
||||
}
|
||||
|
||||
static async build(parentNote: FNote, viewStorage: ViewModeStorage<BoardData>) {
|
||||
const statusAttribute = parentNote.getLabelValue("board:groupBy") ?? "status";
|
||||
|
||||
let persistedData = await viewStorage.restore() ?? {};
|
||||
const { byColumn, newPersistedData } = await getBoardData(parentNote, statusAttribute, persistedData);
|
||||
|
||||
// Use the order from persistedData.columns, then add any new columns found
|
||||
const orderedColumns = persistedData.columns?.map(col => col.value) || [];
|
||||
const allColumns = Array.from(byColumn.keys());
|
||||
const newColumns = allColumns.filter(col => !orderedColumns.includes(col));
|
||||
const columns = [...orderedColumns, ...newColumns];
|
||||
|
||||
if (newPersistedData) {
|
||||
persistedData = newPersistedData;
|
||||
viewStorage.store(persistedData);
|
||||
}
|
||||
|
||||
return new BoardApi(columns, parentNote.noteId, viewStorage, byColumn, persistedData, statusAttribute);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import BoardApi from "./api";
|
||||
import { DragContext, BaseDragHandler } from "./drag_types";
|
||||
|
||||
export class ColumnDragHandler implements BaseDragHandler {
|
||||
private $container: JQuery<HTMLElement>;
|
||||
private api: BoardApi;
|
||||
private context: DragContext;
|
||||
|
||||
constructor(
|
||||
$container: JQuery<HTMLElement>,
|
||||
api: BoardApi,
|
||||
context: DragContext,
|
||||
) {
|
||||
this.$container = $container;
|
||||
this.api = api;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
setupColumnDrag($columnEl: JQuery<HTMLElement>, columnValue: string) {
|
||||
const $titleEl = $columnEl.find('h3[data-column-value]');
|
||||
|
||||
$titleEl.attr("draggable", "true");
|
||||
|
||||
// Delay drag start to allow click detection
|
||||
let dragStartTimer: number | null = null;
|
||||
|
||||
$titleEl.on("mousedown", (e) => {
|
||||
// Don't interfere with editing mode or input field interactions
|
||||
if ($titleEl.hasClass('editing') || $(e.target).is('input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing timer
|
||||
if (dragStartTimer) {
|
||||
clearTimeout(dragStartTimer);
|
||||
dragStartTimer = null;
|
||||
}
|
||||
|
||||
// Set a short delay before enabling dragging
|
||||
dragStartTimer = window.setTimeout(() => {
|
||||
$titleEl.attr("draggable", "true");
|
||||
dragStartTimer = null;
|
||||
}, 150);
|
||||
});
|
||||
|
||||
$titleEl.on("mouseup mouseleave", (e) => {
|
||||
// Don't interfere with editing mode
|
||||
if ($titleEl.hasClass('editing') || $(e.target).is('input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel drag start timer on mouse up or leave
|
||||
if (dragStartTimer) {
|
||||
clearTimeout(dragStartTimer);
|
||||
dragStartTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
$titleEl.on("dragstart", (e) => {
|
||||
// Only start dragging if the target is not an input (for inline editing)
|
||||
if ($(e.target).is('input') || $titleEl.hasClass('editing')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.context.draggedColumn = columnValue;
|
||||
this.context.draggedColumnElement = $columnEl;
|
||||
$columnEl.addClass("column-dragging");
|
||||
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
if (originalEvent.dataTransfer) {
|
||||
originalEvent.dataTransfer.effectAllowed = "move";
|
||||
originalEvent.dataTransfer.setData("text/plain", columnValue);
|
||||
}
|
||||
|
||||
// Prevent note dragging when column is being dragged
|
||||
e.stopPropagation();
|
||||
|
||||
// Setup global drag tracking for better drop indicator positioning
|
||||
this.setupGlobalColumnDragTracking();
|
||||
});
|
||||
|
||||
$titleEl.on("dragend", () => {
|
||||
$columnEl.removeClass("column-dragging");
|
||||
this.context.draggedColumn = null;
|
||||
this.context.draggedColumnElement = null;
|
||||
this.cleanupColumnDropIndicators();
|
||||
this.cleanupGlobalColumnDragTracking();
|
||||
|
||||
// Re-enable draggable
|
||||
$titleEl.attr("draggable", "true");
|
||||
});
|
||||
}
|
||||
|
||||
setupColumnDropZone($columnEl: JQuery<HTMLElement>) {
|
||||
$columnEl.on("dragover", (e) => {
|
||||
// Only handle column drops when a column is being dragged
|
||||
if (this.context.draggedColumn && !this.context.draggedNote) {
|
||||
e.preventDefault();
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
if (originalEvent.dataTransfer) {
|
||||
originalEvent.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
// Don't highlight columns - we only care about the drop indicator position
|
||||
}
|
||||
});
|
||||
|
||||
$columnEl.on("drop", async (e) => {
|
||||
if (this.context.draggedColumn && !this.context.draggedNote) {
|
||||
e.preventDefault();
|
||||
console.log("Column drop event triggered for column:", this.context.draggedColumn);
|
||||
|
||||
// Use the drop indicator position to determine where to place the column
|
||||
await this.handleColumnDrop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.cleanupColumnDropIndicators();
|
||||
this.context.draggedColumn = null;
|
||||
this.context.draggedColumnElement = null;
|
||||
this.cleanupGlobalColumnDragTracking();
|
||||
}
|
||||
|
||||
private setupGlobalColumnDragTracking() {
|
||||
// Add container-level drag tracking for better indicator positioning
|
||||
this.$container.on("dragover.columnDrag", (e) => {
|
||||
if (this.context.draggedColumn) {
|
||||
e.preventDefault();
|
||||
const originalEvent = e.originalEvent as DragEvent;
|
||||
this.showColumnDropIndicator(originalEvent.clientX);
|
||||
}
|
||||
});
|
||||
|
||||
// Add container-level drop handler for column reordering
|
||||
this.$container.on("drop.columnDrag", async (e) => {
|
||||
if (this.context.draggedColumn) {
|
||||
e.preventDefault();
|
||||
console.log("Container drop event triggered for column:", this.context.draggedColumn);
|
||||
await this.handleColumnDrop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private cleanupGlobalColumnDragTracking() {
|
||||
this.$container.off("dragover.columnDrag");
|
||||
this.$container.off("drop.columnDrag");
|
||||
}
|
||||
|
||||
private cleanupColumnDropIndicators() {
|
||||
// Remove column drop indicators
|
||||
this.$container.find(".column-drop-indicator").remove();
|
||||
}
|
||||
|
||||
private showColumnDropIndicator(mouseX: number) {
|
||||
// Clean up existing indicators
|
||||
this.cleanupColumnDropIndicators();
|
||||
|
||||
// Get all columns (excluding the dragged one if it exists)
|
||||
let $allColumns = this.$container.find('.board-column');
|
||||
if (this.context.draggedColumnElement) {
|
||||
$allColumns = $allColumns.not(this.context.draggedColumnElement);
|
||||
}
|
||||
|
||||
let $targetColumn: JQuery<HTMLElement> = $();
|
||||
let insertBefore = false;
|
||||
|
||||
// Find which column the mouse is closest to
|
||||
$allColumns.each((_, columnEl) => {
|
||||
const $column = $(columnEl);
|
||||
const rect = columnEl.getBoundingClientRect();
|
||||
const columnMiddle = rect.left + rect.width / 2;
|
||||
|
||||
if (mouseX >= rect.left && mouseX <= rect.right) {
|
||||
// Mouse is over this column
|
||||
$targetColumn = $column;
|
||||
insertBefore = mouseX < columnMiddle;
|
||||
return false; // Break the loop
|
||||
}
|
||||
});
|
||||
|
||||
// If no column found under mouse, find the closest one
|
||||
if ($targetColumn.length === 0) {
|
||||
let closestDistance = Infinity;
|
||||
$allColumns.each((_, columnEl) => {
|
||||
const $column = $(columnEl);
|
||||
const rect = columnEl.getBoundingClientRect();
|
||||
const columnCenter = rect.left + rect.width / 2;
|
||||
const distance = Math.abs(mouseX - columnCenter);
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
$targetColumn = $column;
|
||||
insertBefore = mouseX < columnCenter;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($targetColumn.length > 0) {
|
||||
const $dropIndicator = $("<div>").addClass("column-drop-indicator");
|
||||
|
||||
if (insertBefore) {
|
||||
$targetColumn.before($dropIndicator);
|
||||
} else {
|
||||
$targetColumn.after($dropIndicator);
|
||||
}
|
||||
|
||||
$dropIndicator.addClass("show");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleColumnDrop() {
|
||||
console.log("handleColumnDrop called for:", this.context.draggedColumn);
|
||||
|
||||
if (!this.context.draggedColumn || !this.context.draggedColumnElement) {
|
||||
console.log("No dragged column or element found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the drop indicator to determine insert position
|
||||
const $dropIndicator = this.$container.find(".column-drop-indicator.show");
|
||||
console.log("Drop indicator found:", $dropIndicator.length > 0);
|
||||
|
||||
if ($dropIndicator.length > 0) {
|
||||
// Get current column order from the API (source of truth)
|
||||
const currentOrder = [...this.api.columns];
|
||||
|
||||
let newOrder = [...currentOrder];
|
||||
|
||||
// Remove dragged column from current position
|
||||
newOrder = newOrder.filter(col => col !== this.context.draggedColumn);
|
||||
|
||||
// Determine insertion position based on drop indicator position
|
||||
const $nextColumn = $dropIndicator.next('.board-column');
|
||||
const $prevColumn = $dropIndicator.prev('.board-column');
|
||||
|
||||
let insertIndex = -1;
|
||||
|
||||
if ($nextColumn.length > 0) {
|
||||
// Insert before the next column
|
||||
const nextColumnValue = $nextColumn.attr('data-column');
|
||||
if (nextColumnValue) {
|
||||
insertIndex = newOrder.indexOf(nextColumnValue);
|
||||
}
|
||||
} else if ($prevColumn.length > 0) {
|
||||
// Insert after the previous column
|
||||
const prevColumnValue = $prevColumn.attr('data-column');
|
||||
if (prevColumnValue) {
|
||||
insertIndex = newOrder.indexOf(prevColumnValue) + 1;
|
||||
}
|
||||
} else {
|
||||
// Insert at the beginning
|
||||
insertIndex = 0;
|
||||
}
|
||||
|
||||
// Insert the dragged column at the determined position
|
||||
if (insertIndex >= 0 && insertIndex <= newOrder.length) {
|
||||
newOrder.splice(insertIndex, 0, this.context.draggedColumn);
|
||||
} else {
|
||||
// Fallback: insert at the end
|
||||
newOrder.push(this.context.draggedColumn);
|
||||
}
|
||||
|
||||
// Update column order in API
|
||||
await this.api.reorderColumns(newOrder);
|
||||
} else {
|
||||
console.warn("No drop indicator found for column drop");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reorder columns:", error);
|
||||
} finally {
|
||||
this.cleanupColumnDropIndicators();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface BoardColumnData {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface BoardData {
|
||||
columns?: BoardColumnData[];
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu.js";
|
||||
import link_context_menu from "../../../menus/link_context_menu.js";
|
||||
import branches from "../../../services/branches.js";
|
||||
import dialog from "../../../services/dialog.js";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
import BoardApi from "./api.js";
|
||||
import type BoardView from "./index.js";
|
||||
|
||||
interface ShowNoteContextMenuArgs {
|
||||
$container: JQuery<HTMLElement>;
|
||||
api: BoardApi;
|
||||
boardView: BoardView;
|
||||
}
|
||||
|
||||
export function setupContextMenu({ $container, api, boardView }: ShowNoteContextMenuArgs) {
|
||||
$container.on("contextmenu", ".board-note", showNoteContextMenu);
|
||||
$container.on("contextmenu", ".board-column h3", showColumnContextMenu);
|
||||
|
||||
function showColumnContextMenu(event: ContextMenuEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const $el = $(event.currentTarget);
|
||||
const column = $el.closest(".board-column").data("column");
|
||||
|
||||
contextMenu.show({
|
||||
x: event.pageX,
|
||||
y: event.pageY,
|
||||
items: [
|
||||
{
|
||||
title: t("board_view.delete-column"),
|
||||
uiIcon: "bx bx-trash",
|
||||
async handler() {
|
||||
const confirmed = await dialog.confirm(t("board_view.delete-column-confirmation"));
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api.removeColumn(column);
|
||||
}
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler() {}
|
||||
});
|
||||
}
|
||||
|
||||
function showNoteContextMenu(event: ContextMenuEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const $el = $(event.currentTarget);
|
||||
const noteId = $el.data("note-id");
|
||||
const branchId = $el.data("branch-id");
|
||||
const column = $el.closest(".board-column").data("column");
|
||||
if (!noteId) return;
|
||||
|
||||
contextMenu.show({
|
||||
x: event.pageX,
|
||||
y: event.pageY,
|
||||
items: [
|
||||
...link_context_menu.getItems(),
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("board_view.move-to"),
|
||||
uiIcon: "bx bx-transfer",
|
||||
items: api.columns.map(columnToMoveTo => ({
|
||||
title: columnToMoveTo,
|
||||
enabled: columnToMoveTo !== column,
|
||||
handler: () => api.changeColumn(noteId, columnToMoveTo)
|
||||
}))
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("board_view.insert-above"),
|
||||
uiIcon: "bx bx-list-plus",
|
||||
handler: () => boardView.insertItemAtPosition(column, branchId, "before")
|
||||
},
|
||||
{
|
||||
title: t("board_view.insert-below"),
|
||||
uiIcon: "bx bx-empty",
|
||||
handler: () => boardView.insertItemAtPosition(column, branchId, "after")
|
||||
},
|
||||
{ title: "----" },
|
||||
{
|
||||
title: t("board_view.delete-note"),
|
||||
uiIcon: "bx bx-trash",
|
||||
handler: () => branches.deleteNotes([ branchId ], false, false)
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId),
|
||||
});
|
||||
}
|
||||
}
|
||||
88
apps/client/src/widgets/view_widgets/board_view/data.ts
Normal file
88
apps/client/src/widgets/view_widgets/board_view/data.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import FBranch from "../../../entities/fbranch";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import { BoardData } from "./config";
|
||||
|
||||
export type ColumnMap = Map<string, {
|
||||
branch: FBranch;
|
||||
note: FNote;
|
||||
}[]>;
|
||||
|
||||
export async function getBoardData(parentNote: FNote, groupByColumn: string, persistedData: BoardData) {
|
||||
const byColumn: ColumnMap = new Map();
|
||||
|
||||
// First, scan all notes to find what columns actually exist
|
||||
await recursiveGroupBy(parentNote.getChildBranches(), byColumn, groupByColumn);
|
||||
|
||||
// Get all columns that exist in the notes
|
||||
const columnsFromNotes = [...byColumn.keys()];
|
||||
|
||||
// Get existing persisted columns and preserve their order
|
||||
const existingPersistedColumns = persistedData.columns || [];
|
||||
const existingColumnValues = existingPersistedColumns.map(c => c.value);
|
||||
|
||||
// Find truly new columns (exist in notes but not in persisted data)
|
||||
const newColumnValues = columnsFromNotes.filter(col => !existingColumnValues.includes(col));
|
||||
|
||||
// Build the complete correct column list: existing + new
|
||||
const allColumns = [
|
||||
...existingPersistedColumns, // Preserve existing order
|
||||
...newColumnValues.map(value => ({ value })) // Add new columns
|
||||
];
|
||||
|
||||
// Remove duplicates (just in case) and ensure we only keep columns that exist in notes or are explicitly preserved
|
||||
const deduplicatedColumns = allColumns.filter((column, index) => {
|
||||
const firstIndex = allColumns.findIndex(c => c.value === column.value);
|
||||
return firstIndex === index; // Keep only the first occurrence
|
||||
});
|
||||
|
||||
// Ensure all persisted columns have empty arrays in byColumn (even if no notes use them)
|
||||
for (const column of deduplicatedColumns) {
|
||||
if (!byColumn.has(column.value)) {
|
||||
byColumn.set(column.value, []);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated persisted data only if there were changes
|
||||
let newPersistedData: BoardData | undefined;
|
||||
const hasChanges = newColumnValues.length > 0 ||
|
||||
existingPersistedColumns.length !== deduplicatedColumns.length ||
|
||||
!existingPersistedColumns.every((col, idx) => deduplicatedColumns[idx]?.value === col.value);
|
||||
|
||||
if (hasChanges) {
|
||||
newPersistedData = {
|
||||
...persistedData,
|
||||
columns: deduplicatedColumns
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
byColumn,
|
||||
newPersistedData
|
||||
};
|
||||
}
|
||||
|
||||
async function recursiveGroupBy(branches: FBranch[], byColumn: ColumnMap, groupByColumn: string) {
|
||||
for (const branch of branches) {
|
||||
const note = await branch.getNote();
|
||||
if (!note) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (note.hasChildren()) {
|
||||
await recursiveGroupBy(note.getChildBranches(), byColumn, groupByColumn);
|
||||
}
|
||||
|
||||
const group = note.getLabelValue(groupByColumn);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byColumn.has(group)) {
|
||||
byColumn.set(group, []);
|
||||
}
|
||||
byColumn.get(group)!.push({
|
||||
branch,
|
||||
note
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { BoardDragHandler } from "./drag_handler";
|
||||
import BoardApi from "./api";
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import ViewModeStorage from "../view_mode_storage";
|
||||
import { BoardData } from "./config";
|
||||
import { t } from "../../../services/i18n.js";
|
||||
|
||||
export interface BoardState {
|
||||
columns: { [key: string]: { note: any; branch: any }[] };
|
||||
columnOrder: string[];
|
||||
}
|
||||
|
||||
export class DifferentialBoardRenderer {
|
||||
private $container: JQuery<HTMLElement>;
|
||||
private api: BoardApi;
|
||||
private dragHandler: BoardDragHandler;
|
||||
private lastState: BoardState | null = null;
|
||||
private onCreateNewItem: (column: string) => void;
|
||||
private updateTimeout: number | null = null;
|
||||
private pendingUpdate = false;
|
||||
private parentNote: FNote;
|
||||
private viewStorage: ViewModeStorage<BoardData>;
|
||||
private onRefreshApi: () => Promise<void>;
|
||||
|
||||
constructor(
|
||||
$container: JQuery<HTMLElement>,
|
||||
api: BoardApi,
|
||||
dragHandler: BoardDragHandler,
|
||||
onCreateNewItem: (column: string) => void,
|
||||
parentNote: FNote,
|
||||
viewStorage: ViewModeStorage<BoardData>,
|
||||
onRefreshApi: () => Promise<void>
|
||||
) {
|
||||
this.$container = $container;
|
||||
this.api = api;
|
||||
this.dragHandler = dragHandler;
|
||||
this.onCreateNewItem = onCreateNewItem;
|
||||
this.parentNote = parentNote;
|
||||
this.viewStorage = viewStorage;
|
||||
this.onRefreshApi = onRefreshApi;
|
||||
}
|
||||
|
||||
async renderBoard(refreshApi = false): Promise<void> {
|
||||
// Refresh API data if requested
|
||||
if (refreshApi) {
|
||||
await this.onRefreshApi();
|
||||
}
|
||||
|
||||
// Debounce rapid updates
|
||||
if (this.updateTimeout) {
|
||||
clearTimeout(this.updateTimeout);
|
||||
}
|
||||
|
||||
this.updateTimeout = window.setTimeout(async () => {
|
||||
await this.performUpdate();
|
||||
this.updateTimeout = null;
|
||||
}, 16); // ~60fps
|
||||
}
|
||||
|
||||
private async performUpdate(): Promise<void> {
|
||||
// Clean up any stray drag indicators before updating
|
||||
this.dragHandler.cleanup();
|
||||
|
||||
const currentState = this.getCurrentState();
|
||||
|
||||
if (!this.lastState) {
|
||||
// First render - do full render
|
||||
await this.fullRender(currentState);
|
||||
} else {
|
||||
// Differential render - only update what changed
|
||||
await this.differentialRender(this.lastState, currentState);
|
||||
}
|
||||
|
||||
this.lastState = currentState;
|
||||
}
|
||||
|
||||
private getCurrentState(): BoardState {
|
||||
const columns: { [key: string]: { note: any; branch: any }[] } = {};
|
||||
const columnOrder: string[] = [];
|
||||
|
||||
for (const column of this.api.columns) {
|
||||
columnOrder.push(column);
|
||||
columns[column] = this.api.getColumn(column) || [];
|
||||
}
|
||||
|
||||
return { columns, columnOrder };
|
||||
}
|
||||
|
||||
private async fullRender(state: BoardState): Promise<void> {
|
||||
this.$container.empty();
|
||||
|
||||
for (const column of state.columnOrder) {
|
||||
const columnItems = state.columns[column];
|
||||
const $columnEl = this.createColumn(column, columnItems);
|
||||
this.$container.append($columnEl);
|
||||
}
|
||||
|
||||
this.addAddColumnButton();
|
||||
}
|
||||
|
||||
private async differentialRender(oldState: BoardState, newState: BoardState): Promise<void> {
|
||||
// Store scroll positions before making changes
|
||||
const scrollPositions = this.saveScrollPositions();
|
||||
|
||||
// Handle column additions/removals
|
||||
this.updateColumns(oldState, newState);
|
||||
|
||||
// Handle card updates within existing columns
|
||||
for (const column of newState.columnOrder) {
|
||||
this.updateColumnCards(column, oldState.columns[column] || [], newState.columns[column]);
|
||||
}
|
||||
|
||||
// Restore scroll positions
|
||||
this.restoreScrollPositions(scrollPositions);
|
||||
}
|
||||
|
||||
private saveScrollPositions(): { [column: string]: number } {
|
||||
const positions: { [column: string]: number } = {};
|
||||
this.$container.find('.board-column').each((_, el) => {
|
||||
const column = $(el).attr('data-column');
|
||||
if (column) {
|
||||
positions[column] = el.scrollTop;
|
||||
}
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
private restoreScrollPositions(positions: { [column: string]: number }): void {
|
||||
this.$container.find('.board-column').each((_, el) => {
|
||||
const column = $(el).attr('data-column');
|
||||
if (column && positions[column] !== undefined) {
|
||||
el.scrollTop = positions[column];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateColumns(oldState: BoardState, newState: BoardState): void {
|
||||
// Check if column order has changed
|
||||
const orderChanged = !this.arraysEqual(oldState.columnOrder, newState.columnOrder);
|
||||
|
||||
if (orderChanged) {
|
||||
// If order changed, we need to reorder the columns in the DOM
|
||||
this.reorderColumns(newState.columnOrder);
|
||||
}
|
||||
|
||||
// Remove columns that no longer exist
|
||||
for (const oldColumn of oldState.columnOrder) {
|
||||
if (!newState.columnOrder.includes(oldColumn)) {
|
||||
this.$container.find(`[data-column="${oldColumn}"]`).remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Add new columns
|
||||
for (const newColumn of newState.columnOrder) {
|
||||
if (!oldState.columnOrder.includes(newColumn)) {
|
||||
const columnItems = newState.columns[newColumn];
|
||||
const $columnEl = this.createColumn(newColumn, columnItems);
|
||||
|
||||
// Insert at correct position
|
||||
const insertIndex = newState.columnOrder.indexOf(newColumn);
|
||||
const $existingColumns = this.$container.find('.board-column');
|
||||
|
||||
if (insertIndex === 0) {
|
||||
this.$container.prepend($columnEl);
|
||||
} else if (insertIndex >= $existingColumns.length) {
|
||||
this.$container.find('.board-add-column').before($columnEl);
|
||||
} else {
|
||||
$($existingColumns[insertIndex - 1]).after($columnEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private arraysEqual(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((val, index) => val === b[index]);
|
||||
}
|
||||
|
||||
private reorderColumns(newOrder: string[]): void {
|
||||
// Get all existing column elements
|
||||
const $columns = this.$container.find('.board-column');
|
||||
const $addColumnButton = this.$container.find('.board-add-column');
|
||||
|
||||
// Create a map of column elements by their data-column attribute
|
||||
const columnElements = new Map<string, JQuery<HTMLElement>>();
|
||||
$columns.each((_, el) => {
|
||||
const $el = $(el);
|
||||
const columnValue = $el.attr('data-column');
|
||||
if (columnValue) {
|
||||
columnElements.set(columnValue, $el);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove all columns from DOM (but keep references)
|
||||
$columns.detach();
|
||||
|
||||
// Re-insert columns in the new order
|
||||
let $insertAfter: JQuery<HTMLElement> | null = null;
|
||||
for (const columnValue of newOrder) {
|
||||
const $columnEl = columnElements.get(columnValue);
|
||||
if ($columnEl) {
|
||||
if ($insertAfter) {
|
||||
$insertAfter.after($columnEl);
|
||||
} else {
|
||||
// Insert at the beginning
|
||||
this.$container.prepend($columnEl);
|
||||
}
|
||||
$insertAfter = $columnEl;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure add column button is at the end
|
||||
if ($addColumnButton.length) {
|
||||
this.$container.append($addColumnButton);
|
||||
}
|
||||
}
|
||||
|
||||
private updateColumnCards(column: string, oldCards: { note: any; branch: any }[], newCards: { note: any; branch: any }[]): void {
|
||||
const $column = this.$container.find(`[data-column="${column}"]`);
|
||||
if (!$column.length) return;
|
||||
|
||||
const $cardContainer = $column;
|
||||
const oldCardIds = oldCards.map(item => item.note.noteId);
|
||||
const newCardIds = newCards.map(item => item.note.noteId);
|
||||
|
||||
// Remove cards that no longer exist
|
||||
$cardContainer.find('.board-note').each((_, el) => {
|
||||
const noteId = $(el).attr('data-note-id');
|
||||
if (noteId && !newCardIds.includes(noteId)) {
|
||||
$(el).addClass('fade-out');
|
||||
setTimeout(() => $(el).remove(), 150);
|
||||
}
|
||||
});
|
||||
|
||||
// Add or update cards
|
||||
for (let i = 0; i < newCards.length; i++) {
|
||||
const item = newCards[i];
|
||||
const noteId = item.note.noteId;
|
||||
const $existingCard = $cardContainer.find(`[data-note-id="${noteId}"]`);
|
||||
const isNewCard = !oldCardIds.includes(noteId);
|
||||
|
||||
if ($existingCard.length) {
|
||||
// Check for changes in title, icon, or color
|
||||
const currentTitle = $existingCard.text().trim();
|
||||
const currentIconClass = $existingCard.attr('data-icon-class');
|
||||
const currentColorClass = $existingCard.attr('data-color-class') || '';
|
||||
|
||||
const newIconClass = item.note.getIcon();
|
||||
const newColorClass = item.note.getColorClass() || '';
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
// Update title if changed
|
||||
if (currentTitle !== item.note.title) {
|
||||
$existingCard.contents().filter(function() {
|
||||
return this.nodeType === 3; // Text nodes
|
||||
}).remove();
|
||||
$existingCard.append(document.createTextNode(item.note.title));
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update icon if changed
|
||||
if (currentIconClass !== newIconClass) {
|
||||
const $icon = $existingCard.find('.icon');
|
||||
$icon.removeClass().addClass('icon').addClass(newIconClass);
|
||||
$existingCard.attr('data-icon-class', newIconClass);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update color if changed
|
||||
if (currentColorClass !== newColorClass) {
|
||||
// Remove old color class if it exists
|
||||
if (currentColorClass) {
|
||||
$existingCard.removeClass(currentColorClass);
|
||||
}
|
||||
// Add new color class if it exists
|
||||
if (newColorClass) {
|
||||
$existingCard.addClass(newColorClass);
|
||||
}
|
||||
$existingCard.attr('data-color-class', newColorClass);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Add subtle animation if there were changes
|
||||
if (hasChanges) {
|
||||
$existingCard.addClass('card-updated');
|
||||
setTimeout(() => $existingCard.removeClass('card-updated'), 300);
|
||||
}
|
||||
|
||||
// Ensure card is in correct position
|
||||
this.ensureCardPosition($existingCard, i, $cardContainer);
|
||||
} else {
|
||||
// Create new card
|
||||
const $newCard = this.createCard(item.note, item.branch, column);
|
||||
$newCard.addClass('fade-in').css('opacity', '0');
|
||||
|
||||
// Insert at correct position
|
||||
if (i === 0) {
|
||||
$cardContainer.find('h3').after($newCard);
|
||||
} else {
|
||||
const $prevCard = $cardContainer.find('.board-note').eq(i - 1);
|
||||
if ($prevCard.length) {
|
||||
$prevCard.after($newCard);
|
||||
} else {
|
||||
$cardContainer.find('.board-new-item').before($newCard);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger fade in animation
|
||||
setTimeout(() => $newCard.css('opacity', '1'), 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureCardPosition($card: JQuery<HTMLElement>, targetIndex: number, $container: JQuery<HTMLElement>): void {
|
||||
const $allCards = $container.find('.board-note');
|
||||
const currentIndex = $allCards.index($card);
|
||||
|
||||
if (currentIndex !== targetIndex) {
|
||||
if (targetIndex === 0) {
|
||||
$container.find('h3').after($card);
|
||||
} else {
|
||||
const $targetPrev = $allCards.eq(targetIndex - 1);
|
||||
if ($targetPrev.length) {
|
||||
$targetPrev.after($card);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createColumn(column: string, columnItems: { note: any; branch: any }[]): JQuery<HTMLElement> {
|
||||
const $columnEl = $("<div>")
|
||||
.addClass("board-column")
|
||||
.attr("data-column", column);
|
||||
|
||||
// Create header
|
||||
const $titleEl = $("<h3>").attr("data-column-value", column);
|
||||
|
||||
// Create title text
|
||||
const $titleText = $("<span>").text(column);
|
||||
|
||||
// Create edit icon
|
||||
const $editIcon = $("<span>")
|
||||
.addClass("edit-icon icon bx bx-edit-alt")
|
||||
.attr("title", "Click to edit column title");
|
||||
|
||||
$titleEl.append($titleText, $editIcon);
|
||||
$columnEl.append($titleEl);
|
||||
|
||||
// Setup column dragging
|
||||
this.dragHandler.setupColumnDrag($columnEl, column);
|
||||
|
||||
// Handle wheel events for scrolling
|
||||
$columnEl.on("wheel", (event) => {
|
||||
const el = $columnEl[0];
|
||||
const needsScroll = el.scrollHeight > el.clientHeight;
|
||||
if (needsScroll) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
// Setup drop zones for both notes and columns
|
||||
this.dragHandler.setupNoteDropZone($columnEl, column);
|
||||
this.dragHandler.setupColumnDropZone($columnEl);
|
||||
|
||||
// Add cards
|
||||
for (const item of columnItems) {
|
||||
if (item.note) {
|
||||
const $noteEl = this.createCard(item.note, item.branch, column);
|
||||
$columnEl.append($noteEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Add "New item" button
|
||||
const $newItemEl = $("<div>")
|
||||
.addClass("board-new-item")
|
||||
.attr("data-column", column)
|
||||
.html(`<span class="icon bx bx-plus"></span> ${t("board_view.new-item")}`);
|
||||
|
||||
$newItemEl.on("click", () => this.onCreateNewItem(column));
|
||||
$columnEl.append($newItemEl);
|
||||
|
||||
return $columnEl;
|
||||
}
|
||||
|
||||
private createCard(note: any, branch: any, column: string): JQuery<HTMLElement> {
|
||||
const $iconEl = $("<span>")
|
||||
.addClass("icon")
|
||||
.addClass(note.getIcon());
|
||||
|
||||
const colorClass = note.getColorClass() || '';
|
||||
|
||||
const $noteEl = $("<div>")
|
||||
.addClass("board-note")
|
||||
.attr("data-note-id", note.noteId)
|
||||
.attr("data-branch-id", branch.branchId)
|
||||
.attr("data-current-column", column)
|
||||
.attr("data-icon-class", note.getIcon())
|
||||
.attr("data-color-class", colorClass)
|
||||
.text(note.title);
|
||||
|
||||
// Add color class to the card if it exists
|
||||
if (colorClass) {
|
||||
$noteEl.addClass(colorClass);
|
||||
}
|
||||
|
||||
$noteEl.prepend($iconEl);
|
||||
$noteEl.on("click", () => appContext.triggerCommand("openInPopup", { noteIdOrPath: note.noteId }));
|
||||
|
||||
// Setup drag functionality
|
||||
this.dragHandler.setupNoteDrag($noteEl, note, branch);
|
||||
|
||||
return $noteEl;
|
||||
}
|
||||
|
||||
private addAddColumnButton(): void {
|
||||
if (this.$container.find('.board-add-column').length === 0) {
|
||||
const $addColumnEl = $("<div>")
|
||||
.addClass("board-add-column")
|
||||
.html(`<span class="icon bx bx-plus"></span> ${t("board_view.add-column")}`);
|
||||
|
||||
this.$container.append($addColumnEl);
|
||||
}
|
||||
}
|
||||
|
||||
forceFullRender(): void {
|
||||
this.lastState = null;
|
||||
if (this.updateTimeout) {
|
||||
clearTimeout(this.updateTimeout);
|
||||
this.updateTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
async flushPendingUpdates(): Promise<void> {
|
||||
if (this.updateTimeout) {
|
||||
clearTimeout(this.updateTimeout);
|
||||
this.updateTimeout = null;
|
||||
await this.performUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
startInlineEditing(noteId: string): void {
|
||||
// Use setTimeout to ensure the card is rendered before trying to edit it
|
||||
setTimeout(() => {
|
||||
const $card = this.$container.find(`[data-note-id="${noteId}"]`);
|
||||
if ($card.length) {
|
||||
this.makeCardEditable($card, noteId);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private makeCardEditable($card: JQuery<HTMLElement>, noteId: string): void {
|
||||
if ($card.hasClass('editing')) {
|
||||
return; // Already editing
|
||||
}
|
||||
|
||||
// Get the current title (get text without icon)
|
||||
const $icon = $card.find('.icon');
|
||||
const currentTitle = $card.text().trim();
|
||||
|
||||
// Add editing class and store original click handler
|
||||
$card.addClass('editing');
|
||||
$card.off('click'); // Remove any existing click handlers temporarily
|
||||
|
||||
// Create input element
|
||||
const $input = $('<input>')
|
||||
.attr('type', 'text')
|
||||
.val(currentTitle)
|
||||
.css({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
color: 'inherit',
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
padding: '0',
|
||||
marginLeft: '0.25em'
|
||||
});
|
||||
|
||||
// Create a flex container to keep icon and input inline
|
||||
const $editContainer = $('<div>')
|
||||
.css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
// Replace content with icon + input in flex container
|
||||
$editContainer.append($icon.clone(), $input);
|
||||
$card.empty().append($editContainer);
|
||||
$input.focus().select();
|
||||
|
||||
const finishEdit = async (save = true) => {
|
||||
if (!$card.hasClass('editing')) {
|
||||
return; // Already finished
|
||||
}
|
||||
|
||||
$card.removeClass('editing');
|
||||
|
||||
let finalTitle = currentTitle;
|
||||
if (save) {
|
||||
const newTitle = $input.val() as string;
|
||||
if (newTitle.trim() && newTitle !== currentTitle) {
|
||||
try {
|
||||
// Update the note title using the board view's server call
|
||||
import('../../../services/server').then(async ({ default: server }) => {
|
||||
await server.put(`notes/${noteId}/title`, { title: newTitle.trim() });
|
||||
finalTitle = newTitle.trim();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update note title:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the card content
|
||||
const iconClass = $card.attr('data-icon-class') || 'bx bx-file';
|
||||
const $newIcon = $('<span>').addClass('icon').addClass(iconClass);
|
||||
$card.text(finalTitle);
|
||||
$card.prepend($newIcon);
|
||||
|
||||
// Re-attach click handler for quick edit (for existing cards)
|
||||
$card.on('click', () => appContext.triggerCommand("openInPopup", { noteIdOrPath: noteId }));
|
||||
};
|
||||
|
||||
$input.on('blur', () => finishEdit(true));
|
||||
$input.on('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
finishEdit(true);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
finishEdit(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import BoardApi from "./api";
|
||||
import { DragContext } from "./drag_types";
|
||||
import { NoteDragHandler } from "./note_drag_handler";
|
||||
import { ColumnDragHandler } from "./column_drag_handler";
|
||||
|
||||
export class BoardDragHandler {
|
||||
private noteDragHandler: NoteDragHandler;
|
||||
private columnDragHandler: ColumnDragHandler;
|
||||
|
||||
constructor(
|
||||
$container: JQuery<HTMLElement>,
|
||||
api: BoardApi,
|
||||
context: DragContext,
|
||||
) {
|
||||
// Initialize specialized drag handlers
|
||||
this.noteDragHandler = new NoteDragHandler($container, api, context);
|
||||
this.columnDragHandler = new ColumnDragHandler($container, api, context);
|
||||
}
|
||||
|
||||
// Note drag methods - delegate to NoteDragHandler
|
||||
setupNoteDrag($noteEl: JQuery<HTMLElement>, note: any, branch: any) {
|
||||
this.noteDragHandler.setupNoteDrag($noteEl, note, branch);
|
||||
}
|
||||
|
||||
setupNoteDropZone($columnEl: JQuery<HTMLElement>, column: string) {
|
||||
this.noteDragHandler.setupNoteDropZone($columnEl, column);
|
||||
}
|
||||
|
||||
// Column drag methods - delegate to ColumnDragHandler
|
||||
setupColumnDrag($columnEl: JQuery<HTMLElement>, columnValue: string) {
|
||||
this.columnDragHandler.setupColumnDrag($columnEl, columnValue);
|
||||
}
|
||||
|
||||
setupColumnDropZone($columnEl: JQuery<HTMLElement>) {
|
||||
this.columnDragHandler.setupColumnDropZone($columnEl);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.noteDragHandler.cleanup();
|
||||
this.columnDragHandler.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Export the drag context type for external use
|
||||
export type { DragContext } from "./drag_types";
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface DragContext {
|
||||
draggedNote: any;
|
||||
draggedBranch: any;
|
||||
draggedNoteElement: JQuery<HTMLElement> | null;
|
||||
draggedColumn: string | null;
|
||||
draggedColumnElement: JQuery<HTMLElement> | null;
|
||||
}
|
||||
|
||||
export interface BaseDragHandler {
|
||||
cleanup(): void;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user