mirror of
https://github.com/zadam/trilium.git
synced 2026-01-09 17:02:16 +01:00
Compare commits
599 Commits
v0.101.3
...
lightweigh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f0021e44e | ||
|
|
2546e4c0dc | ||
|
|
eac5dbb210 | ||
|
|
8b6da981f7 | ||
|
|
7433ca069f | ||
|
|
128049b672 | ||
|
|
0eb3cb1118 | ||
|
|
8fc28716a7 | ||
|
|
af346f455a | ||
|
|
3e5a6c1e51 | ||
|
|
9e3b4435cd | ||
|
|
3a793a3549 | ||
|
|
4f139552f4 | ||
|
|
13f25e9fed | ||
|
|
91db73703b | ||
|
|
d690985b58 | ||
|
|
b5bcf73531 | ||
|
|
2e905c8292 | ||
|
|
4374c92032 | ||
|
|
edde0d0f90 | ||
|
|
32c39384ff | ||
|
|
807ab4be8c | ||
|
|
4da20f4829 | ||
|
|
cb5b491633 | ||
|
|
e76c33c37a | ||
|
|
89fc89603e | ||
|
|
c0bf294457 | ||
|
|
24e076cacf | ||
|
|
1e381b13ca | ||
|
|
f83121ce1d | ||
|
|
b32480f1d3 | ||
|
|
d4468bd97b | ||
|
|
e8711d7cd5 | ||
|
|
35f4d2aaad | ||
|
|
b1f3fe5345 | ||
|
|
9f1b0ac449 | ||
|
|
a84e804fc3 | ||
|
|
3371a31c70 | ||
|
|
724af8e103 | ||
|
|
c5803a2650 | ||
|
|
baf18835be | ||
|
|
3d1c93e58c | ||
|
|
ab0800a9f3 | ||
|
|
dd58eac4b0 | ||
|
|
c6d1457ad7 | ||
|
|
f05fda871c | ||
|
|
22590596da | ||
|
|
8274f9a220 | ||
|
|
b19bf62d7e | ||
|
|
7b436bdf70 | ||
|
|
a1c4a17d64 | ||
|
|
7966cfd09c | ||
|
|
0fe299250e | ||
|
|
adfe490480 | ||
|
|
872ab0864b | ||
|
|
6633b4233d | ||
|
|
a2d873d16f | ||
|
|
a6f52fff3e | ||
|
|
7832f20c89 | ||
|
|
405db7cedb | ||
|
|
ccf4df8e86 | ||
|
|
1beda05e6c | ||
|
|
18a3d9d71a | ||
|
|
25dc9201bf | ||
|
|
b60501dd3f | ||
|
|
cbd2fc3966 | ||
|
|
9bce12a85b | ||
|
|
8523c369e1 | ||
|
|
7c16aeca4a | ||
|
|
8399600e79 | ||
|
|
edac58f3fa | ||
|
|
51d0d848c5 | ||
|
|
1edab8e8da | ||
|
|
e1e294914a | ||
|
|
4668fdc15c | ||
|
|
f1e0d5558c | ||
|
|
c94c54c641 | ||
|
|
18416eb89a | ||
|
|
263c9028e2 | ||
|
|
0b528e9937 | ||
|
|
e905c1ec11 | ||
|
|
ecb27fe9f7 | ||
|
|
a8f6db4b20 | ||
|
|
78262e55ec | ||
|
|
c6197e520d | ||
|
|
299c06c1a6 | ||
|
|
674593b38c | ||
|
|
f5535657ad | ||
|
|
de4d07e904 | ||
|
|
5508b505c8 | ||
|
|
aaca18003d | ||
|
|
8cdfc108ba | ||
|
|
6a0f6fab83 | ||
|
|
ad3be73e1b | ||
|
|
64b212b93e | ||
|
|
60cb8d950e | ||
|
|
61f6f94295 | ||
|
|
ebe7276f40 | ||
|
|
26d299aa44 | ||
|
|
bd45c32251 | ||
|
|
321558a01f | ||
|
|
f5a77477aa | ||
|
|
20c90d1296 | ||
|
|
bbfef0315f | ||
|
|
321fcf34f2 | ||
|
|
b9a59fe0c4 | ||
|
|
01f3c32d92 | ||
|
|
05b9e2ec2a | ||
|
|
c8d3b091fd | ||
|
|
d717a89163 | ||
|
|
8149460547 | ||
|
|
b7ad76827a | ||
|
|
e19e9b3830 | ||
|
|
40b07c3e8a | ||
|
|
a15b84b4e5 | ||
|
|
544c52931c | ||
|
|
9391159413 | ||
|
|
f9e22a9ba9 | ||
|
|
f88ac5dfae | ||
|
|
3459d2906e | ||
|
|
4506b717d5 | ||
|
|
af8744ef2a | ||
|
|
320d8e3b45 | ||
|
|
c20da77f83 | ||
|
|
14e2e85da7 | ||
|
|
c7f0d541c2 | ||
|
|
5ec521b024 | ||
|
|
b3c0be7559 | ||
|
|
d52b735b99 | ||
|
|
639b1f2863 | ||
|
|
7f2cc885fe | ||
|
|
19a365a370 | ||
|
|
9a50da328e | ||
|
|
181e36a7c1 | ||
|
|
178508d245 | ||
|
|
d132d084cf | ||
|
|
494b55d685 | ||
|
|
51513d3779 | ||
|
|
5d474150da | ||
|
|
d3941752f1 | ||
|
|
56b305b1de | ||
|
|
bde472d649 | ||
|
|
c1548b0f54 | ||
|
|
6f04738629 | ||
|
|
f79af7b045 | ||
|
|
527f502083 | ||
|
|
d61e2c6f2c | ||
|
|
ea31d2f446 | ||
|
|
62803a1817 | ||
|
|
a67464b4a0 | ||
|
|
00e7482968 | ||
|
|
688d197472 | ||
|
|
b745fb476e | ||
|
|
047b5a85d2 | ||
|
|
370a0c6a05 | ||
|
|
0d4558fee1 | ||
|
|
76526e0a96 | ||
|
|
70093e0a7d | ||
|
|
458398f2ca | ||
|
|
7a6cc4f51e | ||
|
|
f4ccce7de5 | ||
|
|
f8b5417d6c | ||
|
|
13ce8cf498 | ||
|
|
6c2afc086c | ||
|
|
93d50712a9 | ||
|
|
ed91a44928 | ||
|
|
cd10e66fbb | ||
|
|
d6aa126fcc | ||
|
|
3308c7bdf4 | ||
|
|
56341a1a73 | ||
|
|
0857e1a536 | ||
|
|
5d6b25a29e | ||
|
|
5bc15a5448 | ||
|
|
51a19d0544 | ||
|
|
fb4e912ed0 | ||
|
|
20c2652013 | ||
|
|
971d6ad9e3 | ||
|
|
757fc7a7fe | ||
|
|
e4d0a4554a | ||
|
|
dfab7dbc4b | ||
|
|
0039f4c155 | ||
|
|
23f7dc63b8 | ||
|
|
e485b75a44 | ||
|
|
dbef57d329 | ||
|
|
c650441655 | ||
|
|
e573a8af77 | ||
|
|
b23252d046 | ||
|
|
2f7448dbd4 | ||
|
|
9bf4aa2968 | ||
|
|
d78a7bad3b | ||
|
|
b812177e78 | ||
|
|
4710a6af41 | ||
|
|
a613980ea4 | ||
|
|
20ae1f844b | ||
|
|
69511134e5 | ||
|
|
75952563e4 | ||
|
|
21cf5e1df7 | ||
|
|
9df5505989 | ||
|
|
1809d59193 | ||
|
|
feaa54d660 | ||
|
|
c94bd41162 | ||
|
|
f1f3e66537 | ||
|
|
80363cdc73 | ||
|
|
02e08fdf12 | ||
|
|
42283b2469 | ||
|
|
d3b598a5b2 | ||
|
|
0dd3a03c6b | ||
|
|
2144888447 | ||
|
|
b2549066dc | ||
|
|
cd1f3aa9a7 | ||
|
|
1674401342 | ||
|
|
7ba8dbbf6e | ||
|
|
ad27d9ed0e | ||
|
|
482d2f9624 | ||
|
|
824ef704d4 | ||
|
|
58b73cfc7d | ||
|
|
0465fea2db | ||
|
|
39b75e3561 | ||
|
|
2933db9b16 | ||
|
|
d94914046b | ||
|
|
9cf384b14b | ||
|
|
614a2f0ccb | ||
|
|
5cecc72384 | ||
|
|
3ad37fb602 | ||
|
|
42b048c2bf | ||
|
|
a01bf3dfa1 | ||
|
|
ad60988553 | ||
|
|
c9ae4e4cc6 | ||
|
|
d2639851d5 | ||
|
|
8dc5f9cfa4 | ||
|
|
d99a408e04 | ||
|
|
5f14861682 | ||
|
|
8f8493f3ec | ||
|
|
62af66b5ae | ||
|
|
e8d1fa7447 | ||
|
|
ee03871405 | ||
|
|
345378d97f | ||
|
|
07a463ee52 | ||
|
|
3157047160 | ||
|
|
a1dda3b578 | ||
|
|
e161ffce57 | ||
|
|
0c1859dc43 | ||
|
|
e4dcc0f768 | ||
|
|
74ab591214 | ||
|
|
7bd7996893 | ||
|
|
505ae4eeb5 | ||
|
|
951d6d3ce3 | ||
|
|
5ff7764699 | ||
|
|
0d74998625 | ||
|
|
29b70a12bd | ||
|
|
d84150e97b | ||
|
|
2b2ef4251f | ||
|
|
2840ea0f38 | ||
|
|
542d485267 | ||
|
|
cdd4fbc81d | ||
|
|
bfdddab0a0 | ||
|
|
44d1d01105 | ||
|
|
120bb09171 | ||
|
|
b7af99c671 | ||
|
|
869e0b3973 | ||
|
|
b68613dee4 | ||
|
|
ce0f32e7d5 | ||
|
|
78bc9b59c2 | ||
|
|
23cf3d2923 | ||
|
|
335136f3a3 | ||
|
|
11dd7aef09 | ||
|
|
2d1769e2f9 | ||
|
|
21e26147b0 | ||
|
|
ba301f8c12 | ||
|
|
3420374649 | ||
|
|
644d3a181f | ||
|
|
4be3011a8a | ||
|
|
5aa0a956dd | ||
|
|
7fdb1bdce8 | ||
|
|
57c6cef2bd | ||
|
|
e5599adca1 | ||
|
|
ab392ffb7f | ||
|
|
7585d4b258 | ||
|
|
ff82d9c38c | ||
|
|
920fde69bb | ||
|
|
053812e5f0 | ||
|
|
c2f59c4b6c | ||
|
|
06980fe9b5 | ||
|
|
3f5616f1fc | ||
|
|
b6af3b70b0 | ||
|
|
d8e4547988 | ||
|
|
34f649155e | ||
|
|
11779fe3e3 | ||
|
|
032cde67b0 | ||
|
|
229636a796 | ||
|
|
da9c9ac346 | ||
|
|
3fecc4c648 | ||
|
|
98cefcf77b | ||
|
|
413ee81ffa | ||
|
|
578ca8785e | ||
|
|
da4112c078 | ||
|
|
704c7c881d | ||
|
|
63b6abdb9d | ||
|
|
2e936a3d5c | ||
|
|
606574e18e | ||
|
|
1021879167 | ||
|
|
dc4aa9c607 | ||
|
|
b2c3d78773 | ||
|
|
8d3a0b5295 | ||
|
|
9879d07bec | ||
|
|
7bfce851e7 | ||
|
|
34e81881ec | ||
|
|
0143d6c60d | ||
|
|
267c2bc907 | ||
|
|
316f27d88c | ||
|
|
452b56f470 | ||
|
|
43aeaa4455 | ||
|
|
08b7a6985e | ||
|
|
4bbd8e28c1 | ||
|
|
fcf4c09389 | ||
|
|
2c323cbe80 | ||
|
|
7ec7b6bd7b | ||
|
|
b2378f2a53 | ||
|
|
8bf8d85bb7 | ||
|
|
676173e895 | ||
|
|
d8649c87e0 | ||
|
|
b9456ca466 | ||
|
|
cfccbb8927 | ||
|
|
a18578362a | ||
|
|
2f9f94dee0 | ||
|
|
c84e45ddee | ||
|
|
ea558d8c9d | ||
|
|
b936a35b63 | ||
|
|
b4ef4c2143 | ||
|
|
0ff4756ef4 | ||
|
|
94204b4739 | ||
|
|
bf3a2b768e | ||
|
|
5fb7badfb4 | ||
|
|
239d56f9a3 | ||
|
|
9163fc23f4 | ||
|
|
d225c28fde | ||
|
|
8a3f02e845 | ||
|
|
d0dc92c891 | ||
|
|
8d660f5a2f | ||
|
|
b41b4e77b2 | ||
|
|
267a37d3bd | ||
|
|
0cf23c7d7c | ||
|
|
a632486229 | ||
|
|
64a518a00b | ||
|
|
2f3a914027 | ||
|
|
7182d32d9c | ||
|
|
18381c5d32 | ||
|
|
79327073b4 | ||
|
|
018f2fd789 | ||
|
|
3889392aed | ||
|
|
bd976a25f1 | ||
|
|
01f05ac6fd | ||
|
|
52292cb5a5 | ||
|
|
c6dd1ba0ca | ||
|
|
84f069087c | ||
|
|
76cfced60f | ||
|
|
5c2aea0a6b | ||
|
|
7a883c62df | ||
|
|
ff97461ff8 | ||
|
|
51b0eb74a5 | ||
|
|
2304407986 | ||
|
|
a1ebdc3004 | ||
|
|
fef30f4bea | ||
|
|
eee8d9ab7c | ||
|
|
4f2678d321 | ||
|
|
c473fba628 | ||
|
|
9a9cd8e6a5 | ||
|
|
f5a89aa81a | ||
|
|
3c1beab725 | ||
|
|
79f03ad3ac | ||
|
|
574138a1fb | ||
|
|
6513e2cfca | ||
|
|
43a749b6a7 | ||
|
|
c1d6b3121a | ||
|
|
0d9c8ae4df | ||
|
|
62d8c089ed | ||
|
|
971a76ce11 | ||
|
|
cb33404122 | ||
|
|
bcf72f4624 | ||
|
|
77ad6950e8 | ||
|
|
e2d29aadca | ||
|
|
64ca04ad07 | ||
|
|
b6506a9331 | ||
|
|
fd7222242a | ||
|
|
e36049cd43 | ||
|
|
257f6c5994 | ||
|
|
9098bfb63a | ||
|
|
118d22c4ec | ||
|
|
758df0d85a | ||
|
|
59bbd902fc | ||
|
|
d96528dae4 | ||
|
|
f3cfa84d1d | ||
|
|
dc2ffa516b | ||
|
|
fffab73061 | ||
|
|
0a9ce84cf2 | ||
|
|
07a1734d4b | ||
|
|
6e41d3591d | ||
|
|
4134e5054a | ||
|
|
bb374a5ce2 | ||
|
|
359f398afa | ||
|
|
84425e86e9 | ||
|
|
ebf725c949 | ||
|
|
fc0ea36cf3 | ||
|
|
7836de3f08 | ||
|
|
406232c478 | ||
|
|
9e0c29496f | ||
|
|
480954ee87 | ||
|
|
94039bd9b1 | ||
|
|
667eaca9f2 | ||
|
|
446822a7ae | ||
|
|
f09a3e06f4 | ||
|
|
7c4a56f5f2 | ||
|
|
08f6a32c34 | ||
|
|
3e255fa647 | ||
|
|
c0a90402ef | ||
|
|
37c0f7ec75 | ||
|
|
5e42627bce | ||
|
|
41bcf9524a | ||
|
|
914cf10911 | ||
|
|
855d4d139d | ||
|
|
abb7b0f8c8 | ||
|
|
d78ad52662 | ||
|
|
25b4bcd311 | ||
|
|
a14eed81f6 | ||
|
|
54f51b365a | ||
|
|
c0e0a712ad | ||
|
|
3ab5bbae4d | ||
|
|
cafeb3920a | ||
|
|
fb465b442c | ||
|
|
d3a559a700 | ||
|
|
7768003735 | ||
|
|
f02b3b48e8 | ||
|
|
ba273bb9f4 | ||
|
|
490c539d63 | ||
|
|
ebd60519dd | ||
|
|
56304a4d71 | ||
|
|
32f0f98522 | ||
|
|
b18dd22341 | ||
|
|
8eebae0955 | ||
|
|
ed229e0578 | ||
|
|
dbfaad6c06 | ||
|
|
6e5176b088 | ||
|
|
becf4d7426 | ||
|
|
082040c6e1 | ||
|
|
1ae11ce3a5 | ||
|
|
cf968b3590 | ||
|
|
a3db1ab156 | ||
|
|
7440110a44 | ||
|
|
3638e6b12c | ||
|
|
621ed5b9de | ||
|
|
1e3135dea0 | ||
|
|
8f21c0b34a | ||
|
|
b3feb38369 | ||
|
|
2bf862d5b9 | ||
|
|
cbb7b4ffea | ||
|
|
b2f496048f | ||
|
|
e084bc4c07 | ||
|
|
d9b0660def | ||
|
|
b997452733 | ||
|
|
e699566e62 | ||
|
|
2bd83e6285 | ||
|
|
46e5090445 | ||
|
|
035a311e4d | ||
|
|
850528750c | ||
|
|
645720a725 | ||
|
|
a6c74449aa | ||
|
|
7f05d9cdff | ||
|
|
02d42dc5ff | ||
|
|
e730378b27 | ||
|
|
c14d95f561 | ||
|
|
13b700e0e5 | ||
|
|
f849c4b315 | ||
|
|
c2c19e8ecd | ||
|
|
12875ec308 | ||
|
|
5d12d57a22 | ||
|
|
5cc2296768 | ||
|
|
7c1175995f | ||
|
|
d834cd78a7 | ||
|
|
79d2010bfa | ||
|
|
3f86c809ce | ||
|
|
1570ea77d8 | ||
|
|
99bdd2e433 | ||
|
|
7646061215 | ||
|
|
505a985755 | ||
|
|
e895ea406a | ||
|
|
8b8a78e949 | ||
|
|
1c940ff8a2 | ||
|
|
841cb32835 | ||
|
|
61e96f91d0 | ||
|
|
9f6c07f5cc | ||
|
|
1efb21c627 | ||
|
|
d5b04864c8 | ||
|
|
da28f4505a | ||
|
|
5174deac07 | ||
|
|
e2a628fa2f | ||
|
|
290f488c78 | ||
|
|
b00cb52da5 | ||
|
|
c7bb5ff119 | ||
|
|
faa069b8a1 | ||
|
|
e57f1e6f23 | ||
|
|
73975ab521 | ||
|
|
761a67f238 | ||
|
|
736c69816d | ||
|
|
270339da11 | ||
|
|
aa93bc5492 | ||
|
|
0c9c36ea7e | ||
|
|
af67967502 | ||
|
|
78bec0c782 | ||
|
|
0c77563672 | ||
|
|
241a9e2e7f | ||
|
|
59b691d670 | ||
|
|
a6c515aea0 | ||
|
|
850710926e | ||
|
|
904da14895 | ||
|
|
4c5bc3a3d3 | ||
|
|
ecec661b72 | ||
|
|
fb629f7693 | ||
|
|
13fff33aa4 | ||
|
|
8053221b12 | ||
|
|
ba699f9842 | ||
|
|
eb5ebb53cb | ||
|
|
c26357be40 | ||
|
|
db4af96040 | ||
|
|
5cb3983fe0 | ||
|
|
92292de0ff | ||
|
|
a26923cc6d | ||
|
|
2c4ac4ba30 | ||
|
|
254511bfbf | ||
|
|
e2f6f8a4e4 | ||
|
|
e346963e76 | ||
|
|
5f1bdf7264 | ||
|
|
93a3b29677 | ||
|
|
b157cd909c | ||
|
|
2f24703690 | ||
|
|
27efa8844e | ||
|
|
98de4b6dc3 | ||
|
|
d121de5152 | ||
|
|
5ad7323d03 | ||
|
|
183020a4e3 | ||
|
|
a56a5fe1f5 | ||
|
|
af4fc11a4e | ||
|
|
1d3e971ed7 | ||
|
|
7e7f3ba78f | ||
|
|
03eaebc71c | ||
|
|
3d1f6c4f91 | ||
|
|
8368969932 | ||
|
|
afcd23cb99 | ||
|
|
94d1181fe8 | ||
|
|
7e45aaa1da | ||
|
|
cb016c4307 | ||
|
|
7c7797d35a | ||
|
|
87ab41c80c | ||
|
|
d2391f94c0 | ||
|
|
050ddb8c55 | ||
|
|
bc23e0984a | ||
|
|
07de353207 | ||
|
|
c02491d2e6 | ||
|
|
a6ede8f905 | ||
|
|
22941a9ce0 | ||
|
|
633a09d414 | ||
|
|
29f0881c5a | ||
|
|
60debca37b | ||
|
|
30ea81d0fb | ||
|
|
b1d92c4fe6 | ||
|
|
70f46de2d8 | ||
|
|
f1b2d0b870 | ||
|
|
8a385972fc | ||
|
|
28dd85c1d1 | ||
|
|
827c8e0e72 | ||
|
|
162c076a14 | ||
|
|
9386465de7 | ||
|
|
acca22f3a1 | ||
|
|
f8d84814e0 | ||
|
|
c46cf41842 | ||
|
|
64ab1c4116 | ||
|
|
a6de1041c7 | ||
|
|
c8d34e65ea | ||
|
|
51db729546 | ||
|
|
d2052ad236 | ||
|
|
9c4301467f | ||
|
|
e7355dc0e4 | ||
|
|
4110fec94f | ||
|
|
d5e601eae9 | ||
|
|
4f044c4a57 | ||
|
|
5821c350e1 | ||
|
|
edba8188fe | ||
|
|
1471a72633 | ||
|
|
56834cb88a | ||
|
|
a0f16f9184 | ||
|
|
de80eb4806 | ||
|
|
48a4b81fbe | ||
|
|
e225794f72 | ||
|
|
4eef30f8b5 | ||
|
|
569b09609d | ||
|
|
39838c25c2 | ||
|
|
49e90c08a9 | ||
|
|
e777b06fb8 | ||
|
|
497ec2ac74 | ||
|
|
c5d282d203 |
65
.github/workflows/deploy-app.yml
vendored
Normal file
65
.github/workflows/deploy-app.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Deploy Standalone App
|
||||
|
||||
on:
|
||||
# Trigger on push to main branch
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Only run when docs files change
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
|
||||
# Allow manual triggering from Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Run on pull requests for preview deployments
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/client/**'
|
||||
- 'apps/client-standalone/**'
|
||||
- 'packages/trilium-core/**'
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: Build and Deploy App
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# Required permissions for deployment
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write # For PR preview comments
|
||||
id-token: write # For OIDC authentication (if needed)
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Trigger build of app
|
||||
run: pnpm --filter=client-standalone build
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
with:
|
||||
project_name: "trilium-app"
|
||||
comment_body: "🖥️ App preview is ready"
|
||||
production_url: "https://app.triliumnotes.org"
|
||||
deploy_dir: "apps/client-standalone/dist"
|
||||
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -51,3 +51,4 @@ upload
|
||||
# docs
|
||||
site/
|
||||
apps/*/coverage
|
||||
scripts/translation/.language*.json
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.26.2",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.14.1",
|
||||
"@redocly/cli": "2.14.3",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.3",
|
||||
"react": "19.2.3",
|
||||
|
||||
86
apps/client-standalone/package.json
Normal file
86
apps/client-standalone/package.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "@triliumnext/client-standalone",
|
||||
"version": "1.0.0",
|
||||
"description": "Standalone client for TriliumNext with SQLite WASM backend",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
|
||||
"dev": "vite dev",
|
||||
"test": "vitest",
|
||||
"start-prod": "pnpm build && pnpm http-server dist -p 8888",
|
||||
"coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/multimonth": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.5.1",
|
||||
"@sqlite.org/sqlite-wasm": "3.51.1-build2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/core": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@zumer/snapdom": "2.0.1",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "17.0.0",
|
||||
"i18next": "25.7.3",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"js-sha1": "0.7.0",
|
||||
"js-sha512": "0.9.0",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.27",
|
||||
"knockout": "3.5.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.4.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.1",
|
||||
"react-i18next": "16.5.1",
|
||||
"react-window": "2.2.3",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "7.0.3",
|
||||
"happy-dom": "20.0.11",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
}
|
||||
}
|
||||
3
apps/client-standalone/public/_headers
Normal file
3
apps/client-standalone/public/_headers
Normal file
@@ -0,0 +1,3 @@
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
2
apps/client-standalone/src/desktop.ts
Normal file
2
apps/client-standalone/src/desktop.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export desktop from client
|
||||
export * from "../../client/src/desktop";
|
||||
35
apps/client-standalone/src/index.html
Normal file
35
apps/client-standalone/src/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<!-- <link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest"> -->
|
||||
<title>Trilium Notes</title>
|
||||
</head>
|
||||
|
||||
<body id="trilium-app">
|
||||
<noscript><%= t("javascript-required") %></noscript>
|
||||
|
||||
<script>
|
||||
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
|
||||
document.getElementsByTagName("body")[0].style.display = "none";
|
||||
</script>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<!-- Required for match the PWA's top bar color with the theme -->
|
||||
<!-- This works even when the user directly changes --root-background in CSS -->
|
||||
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
|
||||
|
||||
<!-- Bootstrap (request server for required information) -->
|
||||
<script src="./main.ts" type="module"></script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
254
apps/client-standalone/src/lightweight/browser_router.ts
Normal file
254
apps/client-standalone/src/lightweight/browser_router.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Browser-compatible router that mimics Express routing patterns.
|
||||
* Supports path parameters (e.g., /api/notes/:noteId) and query strings.
|
||||
*/
|
||||
|
||||
import { getContext, routes } from "@triliumnext/core";
|
||||
|
||||
export interface BrowserRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
path: string;
|
||||
params: Record<string, string>;
|
||||
query: Record<string, string | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface BrowserResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: ArrayBuffer | null;
|
||||
}
|
||||
|
||||
export type RouteHandler = (req: BrowserRequest) => unknown | Promise<unknown>;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Convert an Express-style path pattern to a RegExp.
|
||||
* Supports :param syntax for path parameters.
|
||||
*
|
||||
* Examples:
|
||||
* /api/notes/:noteId -> /^\/api\/notes\/([^\/]+)$/
|
||||
* /api/notes/:noteId/revisions -> /^\/api\/notes\/([^\/]+)\/revisions$/
|
||||
*/
|
||||
function pathToRegex(path: string): { pattern: RegExp; paramNames: string[] } {
|
||||
const paramNames: string[] = [];
|
||||
|
||||
// Escape special regex characters except for :param patterns
|
||||
const regexPattern = path
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
||||
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
|
||||
paramNames.push(paramName);
|
||||
return '([^/]+)';
|
||||
});
|
||||
|
||||
return {
|
||||
pattern: new RegExp(`^${regexPattern}$`),
|
||||
paramNames
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse query string into an object.
|
||||
*/
|
||||
function parseQuery(search: string): Record<string, string | undefined> {
|
||||
const query: Record<string, string | undefined> = {};
|
||||
if (!search || search === '?') return query;
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
for (const [key, value] of params) {
|
||||
query[key] = value;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a result to a JSON response.
|
||||
*/
|
||||
function jsonResponse(obj: unknown, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const parsedObj = routes.convertEntitiesToPojo(obj);
|
||||
const body = encoder.encode(JSON.stringify(parsedObj)).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to a text response.
|
||||
*/
|
||||
function textResponse(text: string, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
|
||||
const body = encoder.encode(text).buffer as ArrayBuffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser router class that handles route registration and dispatching.
|
||||
*/
|
||||
export class BrowserRouter {
|
||||
private routes: Route[] = [];
|
||||
|
||||
/**
|
||||
* Register a route handler.
|
||||
*/
|
||||
register(method: string, path: string, handler: RouteHandler): void {
|
||||
const { pattern, paramNames } = pathToRegex(path);
|
||||
this.routes.push({
|
||||
method: method.toUpperCase(),
|
||||
pattern,
|
||||
paramNames,
|
||||
handler
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience methods for common HTTP methods.
|
||||
*/
|
||||
get(path: string, handler: RouteHandler): void {
|
||||
this.register('GET', path, handler);
|
||||
}
|
||||
|
||||
post(path: string, handler: RouteHandler): void {
|
||||
this.register('POST', path, handler);
|
||||
}
|
||||
|
||||
put(path: string, handler: RouteHandler): void {
|
||||
this.register('PUT', path, handler);
|
||||
}
|
||||
|
||||
patch(path: string, handler: RouteHandler): void {
|
||||
this.register('PATCH', path, handler);
|
||||
}
|
||||
|
||||
delete(path: string, handler: RouteHandler): void {
|
||||
this.register('DELETE', path, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a request to the appropriate handler.
|
||||
*/
|
||||
async dispatch(method: string, urlString: string, body?: unknown, headers?: Record<string, string>): Promise<BrowserResponse> {
|
||||
const url = new URL(urlString);
|
||||
const path = url.pathname;
|
||||
const query = parseQuery(url.search);
|
||||
const upperMethod = method.toUpperCase();
|
||||
|
||||
// Parse JSON body if it's an ArrayBuffer and content-type suggests JSON
|
||||
let parsedBody = body;
|
||||
if (body instanceof ArrayBuffer && headers) {
|
||||
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const text = new TextDecoder().decode(body);
|
||||
if (text.trim()) {
|
||||
parsedBody = JSON.parse(text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Router] Failed to parse JSON body:', e);
|
||||
// Keep original body if JSON parsing fails
|
||||
parsedBody = body;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== upperMethod) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (!match) continue;
|
||||
|
||||
// Extract path parameters
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < route.paramNames.length; i++) {
|
||||
params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
|
||||
}
|
||||
|
||||
const request: BrowserRequest = {
|
||||
method: upperMethod,
|
||||
url: urlString,
|
||||
path,
|
||||
params,
|
||||
query,
|
||||
body: parsedBody
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await getContext().init(async () => await route.handler(request));
|
||||
return this.formatResult(result);
|
||||
} catch (error) {
|
||||
return this.formatError(error, `Error handling ${method} ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// No route matched
|
||||
return textResponse(`Not found: ${method} ${path}`, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a handler result into a response.
|
||||
* Follows the same patterns as the server's apiResultHandler.
|
||||
*/
|
||||
private formatResult(result: unknown): BrowserResponse {
|
||||
// Handle [statusCode, response] format
|
||||
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
||||
const [statusCode, response] = result;
|
||||
return jsonResponse(response, statusCode);
|
||||
}
|
||||
|
||||
// Handle undefined (no content) - 204 should have no body
|
||||
if (result === undefined) {
|
||||
return {
|
||||
status: 204,
|
||||
headers: {},
|
||||
body: null
|
||||
};
|
||||
}
|
||||
|
||||
// Default: JSON response with 200
|
||||
return jsonResponse(result, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an error into a response.
|
||||
*/
|
||||
private formatError(error: unknown, context: string): BrowserResponse {
|
||||
console.error('[Router] Handler error:', context, error);
|
||||
|
||||
// Check for known error types
|
||||
if (error && typeof error === 'object') {
|
||||
const err = error as { constructor?: { name?: string }; message?: string };
|
||||
|
||||
if (err.constructor?.name === 'NotFoundError') {
|
||||
return jsonResponse({ message: err.message || 'Not found' }, 404);
|
||||
}
|
||||
|
||||
if (err.constructor?.name === 'ValidationError') {
|
||||
return jsonResponse({ message: err.message || 'Validation error' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return jsonResponse({ message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new router instance.
|
||||
*/
|
||||
export function createRouter(): BrowserRouter {
|
||||
return new BrowserRouter();
|
||||
}
|
||||
92
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
92
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Browser route definitions.
|
||||
* This integrates with the shared route builder from @triliumnext/core.
|
||||
*/
|
||||
|
||||
import { routes, icon_packs as iconPackService } from '@triliumnext/core';
|
||||
import { BrowserRouter, type BrowserRequest } from './browser_router';
|
||||
|
||||
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
|
||||
/**
|
||||
* Wraps a core route handler to work with the BrowserRouter.
|
||||
* Core handlers expect an Express-like request object with params, query, and body.
|
||||
*/
|
||||
function wrapHandler(handler: (req: any) => unknown) {
|
||||
return (req: BrowserRequest) => {
|
||||
// Create an Express-like request object
|
||||
const expressLikeReq = {
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: req.body
|
||||
};
|
||||
return handler(expressLikeReq);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an apiRoute function compatible with buildSharedApiRoutes.
|
||||
* This bridges the core's route registration to the BrowserRouter.
|
||||
*/
|
||||
function createApiRoute(router: BrowserRouter) {
|
||||
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
|
||||
router.register(method, path, wrapHandler(handler));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all API routes on the browser router using the shared builder.
|
||||
*
|
||||
* @param router - The browser router instance
|
||||
*/
|
||||
export function registerRoutes(router: BrowserRouter): void {
|
||||
const apiRoute = createApiRoute(router);
|
||||
routes.buildSharedApiRoutes(apiRoute);
|
||||
apiRoute('get', '/bootstrap', bootstrapRoute);
|
||||
|
||||
// Dummy routes for compatibility.
|
||||
apiRoute("get", "/api/script/widgets", () => []);
|
||||
apiRoute("get", "/api/script/startup", () => []);
|
||||
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }))
|
||||
apiRoute("get", "/api/search/:searchString", () => []);
|
||||
apiRoute("get", "/api/search-templates", () => []);
|
||||
apiRoute("get", "/api/autocomplete", () => []);
|
||||
}
|
||||
|
||||
function bootstrapRoute() {
|
||||
const iconPacks = iconPackService.getIconPacks();
|
||||
const assetPath = ".";
|
||||
|
||||
return {
|
||||
triliumVersion: "1.2.3",
|
||||
assetPath,
|
||||
baseApiUrl: "../api/",
|
||||
themeCssUrl: null,
|
||||
themeUseNextAsBase: "next",
|
||||
device: "desktop",
|
||||
headingStyle: "default",
|
||||
layoutOrientation: "vertical",
|
||||
platform: "web",
|
||||
isElectron: false,
|
||||
isStandalone: true,
|
||||
hasNativeTitleBar: false,
|
||||
hasBackgroundEffects: true,
|
||||
currentLocale: { id: "en", rtl: false },
|
||||
iconPackCss: iconPacks
|
||||
.map(p => iconPackService.generateCss(p, p.builtin
|
||||
? `${assetPath}/fonts/${p.fontAttachmentId}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
|
||||
: `api/attachments/download/${p.fontAttachmentId}`))
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
iconRegistry: iconPackService.generateIconRegistry(iconPacks),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure a router with all routes registered.
|
||||
*/
|
||||
export function createConfiguredRouter(): BrowserRouter {
|
||||
const router = new BrowserRouter();
|
||||
registerRoutes(router);
|
||||
return router;
|
||||
}
|
||||
46
apps/client-standalone/src/lightweight/cls_provider.ts
Normal file
46
apps/client-standalone/src/lightweight/cls_provider.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ExecutionContext } from "@triliumnext/core";
|
||||
|
||||
export default class BrowserExecutionContext implements ExecutionContext {
|
||||
private store: Map<string, any> | null = null;
|
||||
|
||||
get<T = any>(key: string): T | undefined {
|
||||
return this.store?.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
if (!this.store) {
|
||||
throw new Error("ExecutionContext not initialized");
|
||||
}
|
||||
this.store.set(key, value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.store = null;
|
||||
}
|
||||
|
||||
init<T>(callback: () => T): T {
|
||||
// Create a fresh context for this request
|
||||
const prev = this.store;
|
||||
this.store = new Map();
|
||||
|
||||
try {
|
||||
const result = callback();
|
||||
|
||||
// If the result is a Promise, we need to handle cleanup after it resolves
|
||||
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
|
||||
const promise = result as unknown as Promise<any>;
|
||||
return promise.finally(() => {
|
||||
this.store = prev;
|
||||
}) as T;
|
||||
} else {
|
||||
// Synchronous result, clean up immediately
|
||||
this.store = prev;
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
// Always clean up on error (for synchronous errors)
|
||||
this.store = prev;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
145
apps/client-standalone/src/lightweight/crypto_provider.ts
Normal file
145
apps/client-standalone/src/lightweight/crypto_provider.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { CryptoProvider } from "@triliumnext/core";
|
||||
import { sha1 } from "js-sha1";
|
||||
import { sha512 } from "js-sha512";
|
||||
|
||||
interface Cipher {
|
||||
update(data: Uint8Array): Uint8Array;
|
||||
final(): Uint8Array;
|
||||
}
|
||||
|
||||
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Crypto provider for browser environments using the Web Crypto API.
|
||||
*/
|
||||
export default class BrowserCryptoProvider implements CryptoProvider {
|
||||
|
||||
createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
|
||||
const data = typeof content === "string" ? content :
|
||||
new TextDecoder().decode(content);
|
||||
|
||||
const hexHash = algorithm === "sha1" ? sha1(data) : sha512(data);
|
||||
|
||||
// Convert hex string to Uint8Array
|
||||
const bytes = new Uint8Array(hexHash.length / 2);
|
||||
for (let i = 0; i < hexHash.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hexHash.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
// Web Crypto API doesn't support streaming cipher like Node.js
|
||||
// We need to implement a wrapper that collects data and encrypts on final()
|
||||
return new WebCryptoCipher(algorithm, key, iv, "encrypt");
|
||||
}
|
||||
|
||||
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||
return new WebCryptoCipher(algorithm, key, iv, "decrypt");
|
||||
}
|
||||
|
||||
randomBytes(size: number): Uint8Array {
|
||||
const bytes = new Uint8Array(size);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
randomString(length: number): string {
|
||||
const bytes = this.randomBytes(length);
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += CHARS[bytes[i] % CHARS.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cipher implementation that wraps Web Crypto API.
|
||||
* Note: This buffers all data until final() is called, which differs from
|
||||
* Node.js's streaming cipher behavior.
|
||||
*/
|
||||
class WebCryptoCipher implements Cipher {
|
||||
private chunks: Uint8Array[] = [];
|
||||
private algorithm: string;
|
||||
private key: Uint8Array;
|
||||
private iv: Uint8Array;
|
||||
private mode: "encrypt" | "decrypt";
|
||||
private finalized = false;
|
||||
|
||||
constructor(
|
||||
algorithm: "aes-128-cbc",
|
||||
key: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
mode: "encrypt" | "decrypt"
|
||||
) {
|
||||
this.algorithm = algorithm;
|
||||
this.key = key;
|
||||
this.iv = iv;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
update(data: Uint8Array): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
// Buffer the data - Web Crypto doesn't support streaming
|
||||
this.chunks.push(data);
|
||||
// Return empty array since we process everything in final()
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
final(): Uint8Array {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Web Crypto API is async, but we need sync behavior
|
||||
// This is a fundamental limitation that requires architectural changes
|
||||
// For now, throw an error directing users to use async methods
|
||||
throw new Error(
|
||||
"Synchronous cipher finalization not available in browser. " +
|
||||
"The Web Crypto API is async-only. Use finalizeAsync() instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version that actually performs the encryption/decryption.
|
||||
*/
|
||||
async finalizeAsync(): Promise<Uint8Array> {
|
||||
if (this.finalized) {
|
||||
throw new Error("Cipher has already been finalized");
|
||||
}
|
||||
this.finalized = true;
|
||||
|
||||
// Concatenate all chunks
|
||||
const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const data = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunks) {
|
||||
data.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Copy key and iv to ensure they're plain ArrayBuffer-backed
|
||||
const keyBuffer = new Uint8Array(this.key);
|
||||
const ivBuffer = new Uint8Array(this.iv);
|
||||
|
||||
// Import the key
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyBuffer,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
[this.mode]
|
||||
);
|
||||
|
||||
// Perform encryption/decryption
|
||||
const result = this.mode === "encrypt"
|
||||
? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data)
|
||||
: await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data);
|
||||
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
}
|
||||
6538
apps/client-standalone/src/lightweight/db.sql
Normal file
6538
apps/client-standalone/src/lightweight/db.sql
Normal file
File diff suppressed because one or more lines are too long
92
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
92
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { WebSocketMessage } from "@triliumnext/commons";
|
||||
import type { MessagingProvider, MessageHandler } from "@triliumnext/core";
|
||||
|
||||
/**
|
||||
* Messaging provider for browser Worker environments.
|
||||
*
|
||||
* This provider uses the Worker's postMessage API to communicate
|
||||
* with the main thread. It's designed to be used inside a Web Worker
|
||||
* that runs the core services.
|
||||
*
|
||||
* Message flow:
|
||||
* - Outbound (worker → main): Uses self.postMessage() with type: "WS_MESSAGE"
|
||||
* - Inbound (main → worker): Listens to onmessage for type: "WS_MESSAGE"
|
||||
*/
|
||||
export default class WorkerMessagingProvider implements MessagingProvider {
|
||||
private messageHandlers: MessageHandler[] = [];
|
||||
private isDisposed = false;
|
||||
|
||||
constructor() {
|
||||
// Listen for incoming messages from the main thread
|
||||
self.addEventListener("message", this.handleIncomingMessage);
|
||||
console.log("[WorkerMessagingProvider] Initialized");
|
||||
}
|
||||
|
||||
private handleIncomingMessage = (event: MessageEvent) => {
|
||||
if (this.isDisposed) return;
|
||||
|
||||
const { type, message } = event.data || {};
|
||||
|
||||
if (type === "WS_MESSAGE" && message) {
|
||||
// Dispatch to all registered handlers
|
||||
for (const handler of this.messageHandlers) {
|
||||
try {
|
||||
handler(message as WebSocketMessage);
|
||||
} catch (e) {
|
||||
console.error("[WorkerMessagingProvider] Error in message handler:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to all clients (in this case, the main thread).
|
||||
* The main thread is responsible for further distribution if needed.
|
||||
*/
|
||||
sendMessageToAllClients(message: WebSocketMessage): void {
|
||||
if (this.isDisposed) {
|
||||
console.warn("[WorkerMessagingProvider] Cannot send message - provider is disposed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WS_MESSAGE",
|
||||
message
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[WorkerMessagingProvider] Error sending message:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to incoming messages from the main thread.
|
||||
*/
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.push(handler);
|
||||
|
||||
return () => {
|
||||
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of connected "clients".
|
||||
* In worker context, there's always exactly 1 client (the main thread).
|
||||
*/
|
||||
getClientCount(): number {
|
||||
return this.isDisposed ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.isDisposed) return;
|
||||
|
||||
this.isDisposed = true;
|
||||
self.removeEventListener("message", this.handleIncomingMessage);
|
||||
this.messageHandlers = [];
|
||||
console.log("[WorkerMessagingProvider] Disposed");
|
||||
}
|
||||
}
|
||||
615
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
615
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
|
||||
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
||||
import type { BindableValue } from "@sqlite.org/sqlite-wasm";
|
||||
import demoDbSql from "./db.sql?raw";
|
||||
|
||||
// Type definitions for SQLite WASM (the library doesn't export these directly)
|
||||
type Sqlite3Module = Awaited<ReturnType<typeof sqlite3InitModule>>;
|
||||
type Sqlite3Database = InstanceType<Sqlite3Module["oo1"]["DB"]>;
|
||||
type Sqlite3PreparedStatement = ReturnType<Sqlite3Database["prepare"]>;
|
||||
|
||||
/**
|
||||
* Wraps an SQLite WASM PreparedStatement to match the Statement interface
|
||||
* expected by trilium-core.
|
||||
*/
|
||||
class WasmStatement implements Statement {
|
||||
private isRawMode = false;
|
||||
private isPluckMode = false;
|
||||
private isFinalized = false;
|
||||
|
||||
constructor(
|
||||
private stmt: Sqlite3PreparedStatement,
|
||||
private db: Sqlite3Database
|
||||
) {}
|
||||
|
||||
run(...params: unknown[]): RunResult {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call run() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(params);
|
||||
try {
|
||||
// Use step() and then reset instead of stepFinalize()
|
||||
// This allows the statement to be reused
|
||||
this.stmt.step();
|
||||
const changes = this.db.changes();
|
||||
this.stmt.reset();
|
||||
return {
|
||||
changes,
|
||||
lastInsertRowid: 0 // Would need sqlite3_last_insert_rowid for this
|
||||
};
|
||||
} catch (e) {
|
||||
// Reset on error to allow reuse
|
||||
this.stmt.reset();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
get(params: unknown): unknown {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call get() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(Array.isArray(params) ? params : params !== undefined ? [params] : []);
|
||||
try {
|
||||
if (this.stmt.step()) {
|
||||
if (this.isPluckMode) {
|
||||
// In pluck mode, return only the first column value
|
||||
const row = this.stmt.get([]);
|
||||
return Array.isArray(row) && row.length > 0 ? row[0] : undefined;
|
||||
}
|
||||
return this.isRawMode ? this.stmt.get([]) : this.stmt.get({});
|
||||
}
|
||||
return undefined;
|
||||
} finally {
|
||||
this.stmt.reset();
|
||||
}
|
||||
}
|
||||
|
||||
all(...params: unknown[]): unknown[] {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call all() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(params);
|
||||
const results: unknown[] = [];
|
||||
try {
|
||||
while (this.stmt.step()) {
|
||||
if (this.isPluckMode) {
|
||||
// In pluck mode, return only the first column value for each row
|
||||
const row = this.stmt.get([]);
|
||||
if (Array.isArray(row) && row.length > 0) {
|
||||
results.push(row[0]);
|
||||
}
|
||||
} else {
|
||||
results.push(this.isRawMode ? this.stmt.get([]) : this.stmt.get({}));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
this.stmt.reset();
|
||||
}
|
||||
}
|
||||
|
||||
iterate(...params: unknown[]): IterableIterator<unknown> {
|
||||
if (this.isFinalized) {
|
||||
throw new Error("Cannot call iterate() on finalized statement");
|
||||
}
|
||||
|
||||
this.bindParams(params);
|
||||
const stmt = this.stmt;
|
||||
const isRaw = this.isRawMode;
|
||||
const isPluck = this.isPluckMode;
|
||||
|
||||
return {
|
||||
[Symbol.iterator]() {
|
||||
return this;
|
||||
},
|
||||
next(): IteratorResult<unknown> {
|
||||
if (stmt.step()) {
|
||||
if (isPluck) {
|
||||
const row = stmt.get([]);
|
||||
const value = Array.isArray(row) && row.length > 0 ? row[0] : undefined;
|
||||
return { value, done: false };
|
||||
}
|
||||
return { value: isRaw ? stmt.get([]) : stmt.get({}), done: false };
|
||||
}
|
||||
stmt.reset();
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
raw(toggleState?: boolean): this {
|
||||
// In raw mode, rows are returned as arrays instead of objects
|
||||
// If toggleState is undefined, enable raw mode (better-sqlite3 behavior)
|
||||
this.isRawMode = toggleState !== undefined ? toggleState : true;
|
||||
return this;
|
||||
}
|
||||
|
||||
pluck(toggleState?: boolean): this {
|
||||
// In pluck mode, only the first column of each row is returned
|
||||
// If toggleState is undefined, enable pluck mode (better-sqlite3 behavior)
|
||||
this.isPluckMode = toggleState !== undefined ? toggleState : true;
|
||||
return this;
|
||||
}
|
||||
|
||||
private bindParams(params: unknown[]): void {
|
||||
this.stmt.clearBindings();
|
||||
if (params.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single object with named parameters
|
||||
if (params.length === 1 && typeof params[0] === "object" && params[0] !== null && !Array.isArray(params[0])) {
|
||||
const inputBindings = params[0] as { [paramName: string]: BindableValue };
|
||||
|
||||
// SQLite WASM expects parameter names to include the prefix (@ : or $)
|
||||
// better-sqlite3 automatically maps unprefixed names to @name
|
||||
// We need to add the @ prefix for compatibility
|
||||
const bindings: { [paramName: string]: BindableValue } = {};
|
||||
for (const [key, value] of Object.entries(inputBindings)) {
|
||||
// If the key already has a prefix, use it as-is
|
||||
if (key.startsWith('@') || key.startsWith(':') || key.startsWith('$')) {
|
||||
bindings[key] = value;
|
||||
} else {
|
||||
// Add @ prefix to match better-sqlite3 behavior
|
||||
bindings[`@${key}`] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.stmt.bind(bindings);
|
||||
} else {
|
||||
// Handle positional parameters - flatten and cast to BindableValue[]
|
||||
const flatParams = params.flat() as BindableValue[];
|
||||
if (flatParams.length > 0) {
|
||||
this.stmt.bind(flatParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalize(): void {
|
||||
if (!this.isFinalized) {
|
||||
try {
|
||||
this.stmt.finalize();
|
||||
} catch (e) {
|
||||
console.warn("Error finalizing SQLite statement:", e);
|
||||
} finally {
|
||||
this.isFinalized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite database provider for browser environments using SQLite WASM.
|
||||
*
|
||||
* This provider wraps the official @sqlite.org/sqlite-wasm package to provide
|
||||
* a DatabaseProvider implementation compatible with trilium-core.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new BrowserSqlProvider();
|
||||
* await provider.initWasm(); // Initialize SQLite WASM module
|
||||
* provider.loadFromMemory(); // Open an in-memory database
|
||||
* // or
|
||||
* provider.loadFromBuffer(existingDbBuffer); // Load from existing data
|
||||
* ```
|
||||
*/
|
||||
export default class BrowserSqlProvider implements DatabaseProvider {
|
||||
private db?: Sqlite3Database;
|
||||
private sqlite3?: Sqlite3Module;
|
||||
private _inTransaction = false;
|
||||
private initPromise?: Promise<void>;
|
||||
private initError?: Error;
|
||||
private statementCache: Map<string, WasmStatement> = new Map();
|
||||
|
||||
// OPFS state tracking
|
||||
private opfsDbPath?: string;
|
||||
|
||||
/**
|
||||
* Get the SQLite WASM module version info.
|
||||
* Returns undefined if the module hasn't been initialized yet.
|
||||
*/
|
||||
get version(): { libVersion: string; sourceId: string } | undefined {
|
||||
return this.sqlite3?.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SQLite WASM module.
|
||||
* This must be called before using any database operations.
|
||||
* Safe to call multiple times - subsequent calls return the same promise.
|
||||
*
|
||||
* @returns A promise that resolves when the module is initialized
|
||||
* @throws Error if initialization fails
|
||||
*/
|
||||
async initWasm(): Promise<void> {
|
||||
// Return existing promise if already initializing/initialized
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// Fail fast if we already tried and failed
|
||||
if (this.initError) {
|
||||
throw this.initError;
|
||||
}
|
||||
|
||||
this.initPromise = this.doInitWasm();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInitWasm(): Promise<void> {
|
||||
try {
|
||||
console.log("[BrowserSqlProvider] Initializing SQLite WASM...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.sqlite3 = await sqlite3InitModule({
|
||||
print: console.log,
|
||||
printErr: console.error,
|
||||
});
|
||||
|
||||
const initTime = performance.now() - startTime;
|
||||
console.log(
|
||||
`[BrowserSqlProvider] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`,
|
||||
this.sqlite3.version.libVersion
|
||||
);
|
||||
} catch (e) {
|
||||
this.initError = e instanceof Error ? e : new Error(String(e));
|
||||
console.error("[BrowserSqlProvider] SQLite WASM initialization failed:", this.initError);
|
||||
throw this.initError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the SQLite WASM module has been initialized.
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this.sqlite3 !== undefined;
|
||||
}
|
||||
|
||||
// ==================== OPFS Support ====================
|
||||
|
||||
/**
|
||||
* Check if the OPFS VFS is available.
|
||||
* This requires:
|
||||
* - Running in a Worker context
|
||||
* - Browser support for OPFS APIs
|
||||
* - COOP/COEP headers sent by the server (for SharedArrayBuffer)
|
||||
*
|
||||
* @returns true if OPFS VFS is available for use
|
||||
*/
|
||||
isOpfsAvailable(): boolean {
|
||||
this.ensureSqlite3();
|
||||
// SQLite WASM automatically installs the OPFS VFS if the environment supports it
|
||||
// We can check for its presence via sqlite3_vfs_find or the OpfsDb class
|
||||
return this.sqlite3!.oo1.OpfsDb !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or create a database stored in OPFS for persistent storage.
|
||||
* The database will persist across browser sessions.
|
||||
*
|
||||
* Requires COOP/COEP headers to be set by the server:
|
||||
* - Cross-Origin-Opener-Policy: same-origin
|
||||
* - Cross-Origin-Embedder-Policy: require-corp
|
||||
*
|
||||
* @param path - The path for the database file in OPFS (e.g., "/trilium.db")
|
||||
* Paths without a leading slash are treated as relative to OPFS root.
|
||||
* Leading directories are created automatically.
|
||||
* @param options - Additional options
|
||||
* @throws Error if OPFS VFS is not available
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = new BrowserSqlProvider();
|
||||
* await provider.initWasm();
|
||||
* if (provider.isOpfsAvailable()) {
|
||||
* provider.loadFromOpfs("/my-database.db");
|
||||
* } else {
|
||||
* console.warn("OPFS not available, using in-memory database");
|
||||
* provider.loadFromMemory();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
loadFromOpfs(path: string, options: { createIfNotExists?: boolean } = {}): void {
|
||||
this.ensureSqlite3();
|
||||
|
||||
if (!this.isOpfsAvailable()) {
|
||||
throw new Error(
|
||||
"OPFS VFS is not available. This requires:\n" +
|
||||
"1. Running in a Worker context\n" +
|
||||
"2. Browser support for OPFS (Chrome 102+, Firefox 111+, Safari 17+)\n" +
|
||||
"3. COOP/COEP headers from the server:\n" +
|
||||
" Cross-Origin-Opener-Policy: same-origin\n" +
|
||||
" Cross-Origin-Embedder-Policy: require-corp"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[BrowserSqlProvider] Loading database from OPFS: ${path}`);
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// OpfsDb automatically creates directories in the path
|
||||
// Mode 'c' = create if not exists
|
||||
const mode = options.createIfNotExists !== false ? 'c' : '';
|
||||
this.db = new this.sqlite3!.oo1.OpfsDb(path, mode);
|
||||
this.opfsDbPath = path;
|
||||
|
||||
// Configure the database for OPFS
|
||||
// Note: WAL mode requires exclusive locking in OPFS environment
|
||||
this.db.exec("PRAGMA journal_mode = DELETE");
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] OPFS database loaded in ${loadTime.toFixed(2)}ms`);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
console.error(`[BrowserSqlProvider] Failed to load OPFS database: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently open database is stored in OPFS.
|
||||
*/
|
||||
get isUsingOpfs(): boolean {
|
||||
return this.opfsDbPath !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OPFS path of the currently open database.
|
||||
* Returns undefined if not using OPFS.
|
||||
*/
|
||||
get currentOpfsPath(): string | undefined {
|
||||
return this.opfsDbPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database has been initialized with a schema.
|
||||
* This is a simple sanity check that looks for the existence of core tables.
|
||||
*
|
||||
* @returns true if the database appears to be initialized
|
||||
*/
|
||||
isDbInitialized(): boolean {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if the 'notes' table exists (a core table that must exist in an initialized DB)
|
||||
const tableExists = this.db!.selectValue(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'notes'"
|
||||
);
|
||||
|
||||
return tableExists !== undefined;
|
||||
}
|
||||
|
||||
// ==================== End OPFS Support ====================
|
||||
|
||||
loadFromFile(_path: string, _isReadOnly: boolean): void {
|
||||
// Browser environment doesn't have direct file system access.
|
||||
// Use OPFS for persistent storage.
|
||||
throw new Error(
|
||||
"loadFromFile is not supported in browser environment. " +
|
||||
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
|
||||
"or loadFromOpfs() for persistent storage."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty in-memory database.
|
||||
* Data will be lost when the page is closed.
|
||||
*
|
||||
* For persistent storage, use loadFromOpfs() instead.
|
||||
* To load demo data, call initializeDemoDatabase() after this.
|
||||
*/
|
||||
loadFromMemory(): void {
|
||||
this.ensureSqlite3();
|
||||
console.log("[BrowserSqlProvider] Creating in-memory database...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.db = new this.sqlite3!.oo1.DB(":memory:", "c");
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
|
||||
// Initialize with demo data for in-memory databases
|
||||
// (since they won't persist anyway)
|
||||
this.initializeDemoDatabase();
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database with demo/starter data.
|
||||
* This should only be called once when creating a new database.
|
||||
*
|
||||
* For OPFS databases, this is called automatically only if the database
|
||||
* doesn't already exist.
|
||||
*/
|
||||
initializeDemoDatabase(): void {
|
||||
this.ensureDb();
|
||||
console.log("[BrowserSqlProvider] Initializing database with demo data...");
|
||||
const startTime = performance.now();
|
||||
|
||||
this.db!.exec(demoDbSql);
|
||||
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[BrowserSqlProvider] Demo data loaded in ${loadTime.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
loadFromBuffer(buffer: Uint8Array): void {
|
||||
this.ensureSqlite3();
|
||||
// SQLite WASM can deserialize a database from a byte array
|
||||
const p = this.sqlite3!.wasm.allocFromTypedArray(buffer);
|
||||
try {
|
||||
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
|
||||
this.opfsDbPath = undefined; // Not using OPFS
|
||||
|
||||
const rc = this.sqlite3!.capi.sqlite3_deserialize(
|
||||
this.db.pointer!,
|
||||
"main",
|
||||
p,
|
||||
buffer.byteLength,
|
||||
buffer.byteLength,
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE |
|
||||
this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE
|
||||
);
|
||||
if (rc !== 0) {
|
||||
throw new Error(`Failed to deserialize database: ${rc}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.sqlite3!.wasm.dealloc(p);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
backup(_destinationFile: string): void {
|
||||
// In browser, we can serialize the database to a byte array
|
||||
// For actual file backup, we'd need to use File System Access API or download
|
||||
throw new Error(
|
||||
"backup to file is not supported in browser environment. " +
|
||||
"Use serialize() to get the database as a Uint8Array instead."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the database to a byte array.
|
||||
* This can be used to save the database to IndexedDB, download it, etc.
|
||||
*/
|
||||
serialize(): Uint8Array {
|
||||
this.ensureDb();
|
||||
// Use the convenience wrapper which handles all the memory management
|
||||
return this.sqlite3!.capi.sqlite3_js_db_export(this.db!);
|
||||
}
|
||||
|
||||
prepare(query: string): Statement {
|
||||
this.ensureDb();
|
||||
|
||||
// Check if we already have this statement cached
|
||||
if (this.statementCache.has(query)) {
|
||||
return this.statementCache.get(query)!;
|
||||
}
|
||||
|
||||
// Create new statement and cache it
|
||||
const stmt = this.db!.prepare(query);
|
||||
const wasmStatement = new WasmStatement(stmt, this.db!);
|
||||
this.statementCache.set(query, wasmStatement);
|
||||
return wasmStatement;
|
||||
}
|
||||
|
||||
transaction<T>(func: (statement: Statement) => T): Transaction {
|
||||
this.ensureDb();
|
||||
|
||||
const self = this;
|
||||
let savepointCounter = 0;
|
||||
|
||||
// Helper function to execute within a transaction
|
||||
const executeTransaction = (beginStatement: string, ...args: unknown[]): T => {
|
||||
// If we're already in a transaction, use SAVEPOINTs for nesting
|
||||
// This mimics better-sqlite3's behavior
|
||||
if (self._inTransaction) {
|
||||
const savepointName = `sp_${++savepointCounter}_${Date.now()}`;
|
||||
self.db!.exec(`SAVEPOINT ${savepointName}`);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Not in a transaction, start a new one
|
||||
self._inTransaction = true;
|
||||
self.db!.exec(beginStatement);
|
||||
try {
|
||||
const result = func.apply(null, args as [Statement]);
|
||||
self.db!.exec("COMMIT");
|
||||
return result;
|
||||
} catch (e) {
|
||||
self.db!.exec("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
self._inTransaction = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create the transaction function that acts like better-sqlite3's Transaction interface
|
||||
// In better-sqlite3, the transaction function is callable and has .deferred(), .immediate(), etc.
|
||||
const transactionWrapper = Object.assign(
|
||||
// Default call executes with BEGIN (same as immediate)
|
||||
(...args: unknown[]): T => executeTransaction("BEGIN", ...args),
|
||||
{
|
||||
// Deferred transaction - locks acquired on first data access
|
||||
deferred: (...args: unknown[]): T => executeTransaction("BEGIN DEFERRED", ...args),
|
||||
// Immediate transaction - acquires write lock immediately
|
||||
immediate: (...args: unknown[]): T => executeTransaction("BEGIN IMMEDIATE", ...args),
|
||||
// Exclusive transaction - exclusive lock
|
||||
exclusive: (...args: unknown[]): T => executeTransaction("BEGIN EXCLUSIVE", ...args),
|
||||
// Default is same as calling directly
|
||||
default: (...args: unknown[]): T => executeTransaction("BEGIN", ...args)
|
||||
}
|
||||
);
|
||||
|
||||
return transactionWrapper as unknown as Transaction;
|
||||
}
|
||||
|
||||
get inTransaction(): boolean {
|
||||
return this._inTransaction;
|
||||
}
|
||||
|
||||
exec(query: string): void {
|
||||
this.ensureDb();
|
||||
this.db!.exec(query);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Clean up all cached statements first
|
||||
for (const statement of this.statementCache.values()) {
|
||||
try {
|
||||
statement.finalize();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
console.warn("Error finalizing statement during cleanup:", e);
|
||||
}
|
||||
}
|
||||
this.statementCache.clear();
|
||||
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = undefined;
|
||||
}
|
||||
|
||||
// Reset OPFS state
|
||||
this.opfsDbPath = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of rows changed by the last INSERT, UPDATE, or DELETE statement.
|
||||
*/
|
||||
changes(): number {
|
||||
this.ensureDb();
|
||||
return this.db!.changes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is currently open.
|
||||
*/
|
||||
isOpen(): boolean {
|
||||
return this.db !== undefined && this.db.isOpen();
|
||||
}
|
||||
|
||||
private ensureSqlite3(): void {
|
||||
if (!this.sqlite3) {
|
||||
throw new Error(
|
||||
"SQLite WASM module not initialized. Call initialize() first with the sqlite3 module."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDb(): void {
|
||||
this.ensureSqlite3();
|
||||
if (!this.db) {
|
||||
throw new Error("Database not opened. Call loadFromMemory(), loadFromBuffer(), or loadFromOpfs() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
89
apps/client-standalone/src/local-bridge.ts
Normal file
89
apps/client-standalone/src/local-bridge.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// public/local-bridge.js
|
||||
let localWorker: Worker | null = null;
|
||||
const pending = new Map();
|
||||
|
||||
export function startLocalServerWorker() {
|
||||
if (localWorker) return localWorker;
|
||||
|
||||
localWorker = new Worker(new URL("./local-server-worker.js", import.meta.url), { type: "module" });
|
||||
|
||||
// Handle worker errors during initialization
|
||||
localWorker.onerror = (event) => {
|
||||
console.error("[LocalBridge] Worker error:", event);
|
||||
// Reject all pending requests
|
||||
for (const [id, resolver] of pending) {
|
||||
resolver.reject(new Error(`Worker error: ${event.message}`));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
localWorker.onmessage = (event) => {
|
||||
const msg = event.data;
|
||||
|
||||
// Handle worker error reports
|
||||
if (msg?.type === "WORKER_ERROR") {
|
||||
console.error("[LocalBridge] Worker reported error:", msg.error);
|
||||
// Reject all pending requests with the error
|
||||
for (const [id, resolver] of pending) {
|
||||
resolver.reject(new Error(msg.error?.message || "Unknown worker error"));
|
||||
}
|
||||
pending.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg || msg.type !== "LOCAL_RESPONSE") return;
|
||||
|
||||
const { id, response, error } = msg;
|
||||
const resolver = pending.get(id);
|
||||
if (!resolver) return;
|
||||
pending.delete(id);
|
||||
|
||||
if (error) resolver.reject(new Error(error));
|
||||
else resolver.resolve(response);
|
||||
};
|
||||
|
||||
return localWorker;
|
||||
}
|
||||
|
||||
export function attachServiceWorkerBridge() {
|
||||
navigator.serviceWorker.addEventListener("message", async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.type !== "LOCAL_FETCH") return;
|
||||
|
||||
const port = event.ports && event.ports[0];
|
||||
if (!port) return;
|
||||
|
||||
try {
|
||||
startLocalServerWorker();
|
||||
|
||||
const id = msg.id;
|
||||
const req = msg.request;
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
// Transfer body to worker for efficiency (if present)
|
||||
localWorker.postMessage({
|
||||
type: "LOCAL_REQUEST",
|
||||
id,
|
||||
request: req
|
||||
}, req.body ? [req.body] : []);
|
||||
});
|
||||
|
||||
port.postMessage({
|
||||
type: "LOCAL_FETCH_RESPONSE",
|
||||
id,
|
||||
response
|
||||
}, response.body ? [response.body] : []);
|
||||
} catch (e) {
|
||||
port.postMessage({
|
||||
type: "LOCAL_FETCH_RESPONSE",
|
||||
id: msg.id,
|
||||
response: {
|
||||
status: 500,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
body: new TextEncoder().encode(String(e?.message || e)).buffer
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
222
apps/client-standalone/src/local-server-worker.ts
Normal file
222
apps/client-standalone/src/local-server-worker.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
// public/local-server-worker.js
|
||||
// This will eventually import your core server and DB provider.
|
||||
// import { createCoreServer } from "@trilium/core"; (bundled)
|
||||
|
||||
import BrowserExecutionContext from './lightweight/cls_provider';
|
||||
import BrowserCryptoProvider from './lightweight/crypto_provider';
|
||||
import BrowserSqlProvider from './lightweight/sql_provider';
|
||||
import WorkerMessagingProvider from './lightweight/messaging_provider';
|
||||
import { BrowserRouter } from './lightweight/browser_router';
|
||||
import { createConfiguredRouter } from './lightweight/browser_routes';
|
||||
|
||||
// Global error handlers - MUST be set up before any async imports
|
||||
self.onerror = (message, source, lineno, colno, error) => {
|
||||
console.error("[Worker] Uncaught error:", message, source, lineno, colno, error);
|
||||
// Try to notify the main thread about the error
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(message),
|
||||
source,
|
||||
lineno,
|
||||
colno,
|
||||
stack: error?.stack
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Can't even post message, just log
|
||||
console.error("[Worker] Failed to report error:", e);
|
||||
}
|
||||
return false; // Don't suppress the error
|
||||
};
|
||||
|
||||
self.onunhandledrejection = (event) => {
|
||||
console.error("[Worker] Unhandled rejection:", event.reason);
|
||||
try {
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(event.reason?.message || event.reason),
|
||||
stack: event.reason?.stack
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Worker] Failed to report rejection:", e);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("[Worker] Error handlers installed");
|
||||
|
||||
// Shared SQL provider instance
|
||||
const sqlProvider = new BrowserSqlProvider();
|
||||
|
||||
// Messaging provider for worker-to-main-thread communication
|
||||
const messagingProvider = new WorkerMessagingProvider();
|
||||
|
||||
// Core module, router, and initialization state
|
||||
let coreModule: typeof import("@triliumnext/core") | null = null;
|
||||
let router: BrowserRouter | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let initError: Error | null = null;
|
||||
|
||||
/**
|
||||
* Initialize SQLite WASM and load the core module.
|
||||
* This happens once at worker startup.
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
if (initPromise) {
|
||||
return initPromise; // Already initializing
|
||||
}
|
||||
if (initError) {
|
||||
throw initError; // Failed before, don't retry
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
console.log("[Worker] Initializing SQLite WASM...");
|
||||
await sqlProvider.initWasm();
|
||||
|
||||
// Try to use OPFS for persistent storage
|
||||
if (sqlProvider.isOpfsAvailable()) {
|
||||
console.log("[Worker] OPFS available, loading persistent database...");
|
||||
sqlProvider.loadFromOpfs("/trilium.db");
|
||||
|
||||
// Check if database is initialized (schema exists)
|
||||
if (!sqlProvider.isDbInitialized()) {
|
||||
console.log("[Worker] Database not initialized, loading demo data...");
|
||||
sqlProvider.initializeDemoDatabase();
|
||||
console.log("[Worker] Demo data loaded");
|
||||
} else {
|
||||
console.log("[Worker] Existing initialized database loaded");
|
||||
}
|
||||
} else {
|
||||
// Fall back to in-memory database (non-persistent)
|
||||
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
|
||||
console.warn("[Worker] To enable persistence, ensure COOP/COEP headers are set by the server");
|
||||
sqlProvider.loadFromMemory();
|
||||
}
|
||||
|
||||
console.log("[Worker] Database loaded");
|
||||
|
||||
console.log("[Worker] Loading @triliumnext/core...");
|
||||
coreModule = await import("@triliumnext/core");
|
||||
coreModule.initializeCore({
|
||||
executionContext: new BrowserExecutionContext(),
|
||||
crypto: new BrowserCryptoProvider(),
|
||||
messaging: messagingProvider,
|
||||
dbConfig: {
|
||||
provider: sqlProvider,
|
||||
isReadOnly: false,
|
||||
onTransactionCommit: () => {
|
||||
// No-op for now
|
||||
},
|
||||
onTransactionRollback: () => {
|
||||
// No-op for now
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[Worker] Supported routes", Object.keys(coreModule.routes));
|
||||
|
||||
// Create and configure the router
|
||||
router = createConfiguredRouter();
|
||||
console.log("[Worker] Router configured");
|
||||
|
||||
console.log("[Worker] Initializing becca...");
|
||||
await coreModule.becca_loader.beccaLoaded;
|
||||
|
||||
console.log("[Worker] Initialization complete");
|
||||
} catch (error) {
|
||||
initError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error("[Worker] Initialization failed:", initError);
|
||||
throw initError;
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the worker is initialized before processing requests.
|
||||
* Returns the router if initialization was successful.
|
||||
*/
|
||||
async function ensureInitialized() {
|
||||
await initialize();
|
||||
if (!router) {
|
||||
throw new Error("Router not initialized");
|
||||
}
|
||||
return router;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function jsonResponse(obj: unknown, status = 200, extraHeaders = {}) {
|
||||
const body = encoder.encode(JSON.stringify(obj)).buffer;
|
||||
return {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
interface LocalRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Main dispatch
|
||||
async function dispatch(request: LocalRequest) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
console.log("[Worker] Dispatch:", url.pathname);
|
||||
|
||||
// Ensure initialization is complete and get the router
|
||||
const appRouter = await ensureInitialized();
|
||||
|
||||
// Dispatch to the router
|
||||
return appRouter.dispatch(request.method, request.url, request.body, request.headers);
|
||||
}
|
||||
|
||||
// Start initialization immediately when the worker loads
|
||||
console.log("[Worker] Starting initialization...");
|
||||
initialize().catch(err => {
|
||||
console.error("[Worker] Initialization failed:", err);
|
||||
// Post error to main thread
|
||||
self.postMessage({
|
||||
type: "WORKER_ERROR",
|
||||
error: {
|
||||
message: String(err?.message || err),
|
||||
stack: err?.stack
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.type !== "LOCAL_REQUEST") return;
|
||||
|
||||
const { id, request } = msg;
|
||||
console.log("[Worker] Received LOCAL_REQUEST:", id, request.method, request.url);
|
||||
|
||||
try {
|
||||
const response = await dispatch(request);
|
||||
console.log("[Worker] Dispatch completed, sending response:", id);
|
||||
|
||||
// Transfer body back (if any) - use options object for proper typing
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
response
|
||||
}, { transfer: response.body ? [response.body] : [] });
|
||||
} catch (e) {
|
||||
console.error("[Worker] Dispatch error:", e);
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "LOCAL_RESPONSE",
|
||||
id,
|
||||
error: String((e as Error)?.message || e)
|
||||
});
|
||||
}
|
||||
};
|
||||
189
apps/client-standalone/src/main.ts
Normal file
189
apps/client-standalone/src/main.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
|
||||
|
||||
async function waitForServiceWorkerControl(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
throw new Error("Service Worker not supported in this browser");
|
||||
}
|
||||
|
||||
// If already controlling, we're good
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker already controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Bootstrap] Waiting for service worker to take control...");
|
||||
|
||||
// Register service worker
|
||||
const registration = await navigator.serviceWorker.register("./sw.js", { scope: "/" });
|
||||
|
||||
// Wait for it to be ready (installed + activated)
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Check if we're now controlling
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log("[Bootstrap] Service worker now controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
// If not controlling yet, we need to reload the page for SW to take control
|
||||
// This is standard PWA behavior on first install
|
||||
console.log("[Bootstrap] Service worker installed but not controlling yet - reloading page");
|
||||
|
||||
// Wait a tiny bit for SW to fully activate
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Reload to let SW take control
|
||||
window.location.reload();
|
||||
|
||||
// Throw to stop execution (page will reload)
|
||||
throw new Error("Reloading for service worker activation");
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, maxRetries = 3, delayMs = 500): Promise<Response> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`[Bootstrap] Fetching ${url} (attempt ${attempt + 1}/${maxRetries})`);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Check if response has content
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
throw new Error(`Invalid content-type: ${contentType || "none"}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
console.warn(`[Bootstrap] Fetch attempt ${attempt + 1} failed:`, err);
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
// Exponential backoff
|
||||
const delay = delayMs * Math.pow(2, attempt);
|
||||
console.log(`[Bootstrap] Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch ${url} after ${maxRetries} attempts: ${lastError?.message}`);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
/* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.global = globalThis;
|
||||
|
||||
try {
|
||||
// 1) Start local worker ASAP (so /bootstrap is fast)
|
||||
startLocalServerWorker();
|
||||
|
||||
// 2) Bridge SW -> local worker
|
||||
attachServiceWorkerBridge();
|
||||
|
||||
// 3) Wait for service worker to control the page (may reload on first install)
|
||||
await waitForServiceWorkerControl();
|
||||
|
||||
// 4) Now fetch bootstrap - SW is guaranteed to intercept this
|
||||
await setupGlob();
|
||||
|
||||
loadStylesheets();
|
||||
loadIcons();
|
||||
setBodyAttributes();
|
||||
await loadScripts();
|
||||
} catch (err) {
|
||||
// If error is from reload, it will stop here (page reloads)
|
||||
// Otherwise, show error to user
|
||||
if (err instanceof Error && err.message.includes("Reloading")) {
|
||||
// Page is reloading, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[Bootstrap] Fatal error:", err);
|
||||
document.body.innerHTML = `
|
||||
<div style="padding: 40px; max-width: 600px; margin: 0 auto; font-family: system-ui, sans-serif;">
|
||||
<h1 style="color: #d32f2f;">Failed to Initialize</h1>
|
||||
<p>The application failed to start. Please check the browser console for details.</p>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow: auto;">${err instanceof Error ? err.message : String(err)}</pre>
|
||||
<button onclick="location.reload()" style="padding: 12px 24px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
async function setupGlob() {
|
||||
const response = await fetchWithRetry("/bootstrap");
|
||||
console.log("Service worker state", navigator.serviceWorker.controller);
|
||||
console.log("Resp", response);
|
||||
const json = await response.json();
|
||||
console.log("Bootstrap", json);
|
||||
|
||||
window.glob = {
|
||||
...json,
|
||||
activeDialog: null
|
||||
};
|
||||
}
|
||||
|
||||
function loadStylesheets() {
|
||||
const { assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
|
||||
const cssToLoad = [];
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
|
||||
if (themeCssUrl) {
|
||||
cssToLoad.push(themeCssUrl);
|
||||
}
|
||||
if (themeUseNextAsBase === "next") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`)
|
||||
} else if (themeUseNextAsBase === "next-dark") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`)
|
||||
} else if (themeUseNextAsBase === "next-light") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`)
|
||||
}
|
||||
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
|
||||
|
||||
for (const href of cssToLoad) {
|
||||
const linkEl = document.createElement("link");
|
||||
linkEl.href = href;
|
||||
linkEl.rel = "stylesheet";
|
||||
document.body.appendChild(linkEl);
|
||||
}
|
||||
}
|
||||
|
||||
function loadIcons() {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerText = window.glob.iconPackCss;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
function setBodyAttributes() {
|
||||
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
|
||||
const classesToSet = [
|
||||
device,
|
||||
`heading-style-${headingStyle}`,
|
||||
`layout-${layoutOrientation}`,
|
||||
`platform-${platform}`,
|
||||
isElectron && "isElectron",
|
||||
hasNativeTitleBar && "native-titlebar",
|
||||
hasBackgroundEffects && "background-effects"
|
||||
].filter(Boolean);
|
||||
|
||||
for (const classToSet of classesToSet) {
|
||||
document.body.classList.add(classToSet);
|
||||
}
|
||||
|
||||
document.body.lang = currentLocale.id;
|
||||
document.body.dir = currentLocale.rtl ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
await import("./runtime.js");
|
||||
await import("./desktop.js");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
2
apps/client-standalone/src/runtime.ts
Normal file
2
apps/client-standalone/src/runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export runtime from client
|
||||
export * from "../../client/src/runtime";
|
||||
185
apps/client-standalone/src/sw.ts
Normal file
185
apps/client-standalone/src/sw.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// public/sw.js
|
||||
const VERSION = "localserver-v1.4";
|
||||
const STATIC_CACHE = `static-${VERSION}`;
|
||||
|
||||
// Check if running in dev mode (passed via URL parameter)
|
||||
const isDev = true;
|
||||
|
||||
if (isDev) {
|
||||
console.log('[Service Worker] Running in DEV mode - caching disabled');
|
||||
}
|
||||
|
||||
// Adjust these to your routes:
|
||||
const LOCAL_FIRST_PREFIXES = [
|
||||
"/bootstrap",
|
||||
"/api/",
|
||||
"/sync/",
|
||||
"/search/"
|
||||
];
|
||||
|
||||
// Optional: basic precache list (keep small; you can expand later)
|
||||
const PRECACHE_URLS = [
|
||||
// "/",
|
||||
// "/index.html",
|
||||
// "/manifest.webmanifest",
|
||||
// "/favicon.ico",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Skip precaching in dev mode
|
||||
if (!isDev) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
await cache.addAll(PRECACHE_URLS);
|
||||
}
|
||||
self.skipWaiting();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil((async () => {
|
||||
// Cleanup old caches
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => (k === STATIC_CACHE ? Promise.resolve() : caches.delete(k))));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
function isLocalFirst(url) {
|
||||
return LOCAL_FIRST_PREFIXES.some((p) => url.pathname.startsWith(p));
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
// In dev mode, always bypass cache
|
||||
if (isDev) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
try {
|
||||
const fresh = await fetch(request);
|
||||
// Cache only successful GETs
|
||||
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
|
||||
return fresh;
|
||||
} catch (error) {
|
||||
// Fallback to cache if network fails
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardToClientLocalServer(request, clientId) {
|
||||
// Find a client to handle the request (prefer the initiating client if available)
|
||||
let client = clientId ? await self.clients.get(clientId) : null;
|
||||
|
||||
if (!client) {
|
||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
client = all[0] || null;
|
||||
}
|
||||
|
||||
// If no page is available, fall back to network
|
||||
if (!client) return fetch(request);
|
||||
|
||||
const reqUrl = request.url;
|
||||
const headersObj = {};
|
||||
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
|
||||
|
||||
const body = (request.method === "GET" || request.method === "HEAD")
|
||||
? null
|
||||
: await request.arrayBuffer();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const channel = new MessageChannel();
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Local server timeout"));
|
||||
}, 30_000);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(event.data);
|
||||
};
|
||||
channel.port1.onmessageerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Local server message error"));
|
||||
};
|
||||
});
|
||||
|
||||
// Send to the client with a reply port
|
||||
client.postMessage({
|
||||
type: "LOCAL_FETCH",
|
||||
id,
|
||||
request: {
|
||||
url: reqUrl,
|
||||
method: request.method,
|
||||
headers: headersObj,
|
||||
body // ArrayBuffer or null
|
||||
}
|
||||
}, [channel.port2]);
|
||||
|
||||
const localResp = await responsePromise;
|
||||
|
||||
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
|
||||
// Protocol mismatch; fall back
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// localResp.response: { status, headers, body }
|
||||
const { status, headers, body: respBody } = localResp.response;
|
||||
|
||||
const respHeaders = new Headers();
|
||||
if (headers) {
|
||||
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
|
||||
}
|
||||
|
||||
return new Response(respBody ? respBody : null, {
|
||||
status: status || 200,
|
||||
headers: respHeaders
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Only handle same-origin
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// HTML files: network-first to ensure updates are reflected immediately
|
||||
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
|
||||
event.respondWith(networkFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first for performance
|
||||
if (event.request.method === "GET" && !isLocalFirst(url)) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// API-ish: local-first via bridge
|
||||
if (isLocalFirst(url)) {
|
||||
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
31
apps/client-standalone/src/vite-env.d.ts
vendored
Normal file
31
apps/client-standalone/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
glob: {
|
||||
assetPath: string;
|
||||
themeCssUrl?: string;
|
||||
themeUseNextAsBase?: string;
|
||||
iconPackCss: string;
|
||||
device: string;
|
||||
headingStyle: string;
|
||||
layoutOrientation: string;
|
||||
platform: string;
|
||||
isElectron: boolean;
|
||||
hasNativeTitleBar: boolean;
|
||||
hasBackgroundEffects: boolean;
|
||||
currentLocale: {
|
||||
id: string;
|
||||
rtl: boolean;
|
||||
};
|
||||
activeDialog: any;
|
||||
};
|
||||
global: typeof globalThis;
|
||||
}
|
||||
26
apps/client-standalone/tsconfig.app.json
Normal file
26
apps/client-standalone/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../client/src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"../client/src/**/*.spec.ts",
|
||||
"../client/src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
7
apps/client-standalone/tsconfig.json
Normal file
7
apps/client-standalone/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.spec.json" }
|
||||
]
|
||||
}
|
||||
18
apps/client-standalone/tsconfig.spec.json
Normal file
18
apps/client-standalone/tsconfig.spec.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"happy-dom"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
173
apps/client-standalone/vite.config.mts
Normal file
173
apps/client-standalone/vite.config.mts
Normal file
@@ -0,0 +1,173 @@
|
||||
import preact from "@preact/preset-vite";
|
||||
import { defineConfig } from 'vite';
|
||||
import { join } from 'path';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
const assets = ["assets", "stylesheets", "fonts", "translations"];
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// Watch client files and trigger reload in development
|
||||
const clientWatchPlugin = () => ({
|
||||
name: 'client-watch',
|
||||
configureServer(server: any) {
|
||||
if (isDev) {
|
||||
// Watch client source files (adjusted for new root)
|
||||
server.watcher.add('../../client/src/**/*');
|
||||
server.watcher.on('change', (file: string) => {
|
||||
if (file.includes('../../client/src/')) {
|
||||
server.ws.send({
|
||||
type: 'full-reload'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Always copy SQLite WASM files so they're available to the module
|
||||
const sqliteWasmPlugin = viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm",
|
||||
dest: "assets"
|
||||
},
|
||||
{
|
||||
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
|
||||
dest: "assets"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let plugins: any = [
|
||||
preact({
|
||||
babel: {
|
||||
compact: !isDev
|
||||
}
|
||||
}),
|
||||
sqliteWasmPlugin, // Always include SQLite WASM files
|
||||
viteStaticCopy({
|
||||
targets: assets.map((asset) => ({
|
||||
src: `../../client/src/${asset}/*`,
|
||||
dest: asset
|
||||
})),
|
||||
// Enable watching in development
|
||||
...(isDev && {
|
||||
watch: {
|
||||
reloadPageOnChange: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
// Watch client files for changes in development
|
||||
...(isDev ? [clientWatchPlugin()] : [])
|
||||
];
|
||||
|
||||
if (!isDev) {
|
||||
plugins = [
|
||||
...plugins,
|
||||
viteStaticCopy({
|
||||
structured: true,
|
||||
targets: [
|
||||
{
|
||||
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
|
||||
dest: "",
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
|
||||
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
|
||||
base: "",
|
||||
plugins,
|
||||
publicDir: join(__dirname, 'public'),
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "react",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "react-dom",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "@client",
|
||||
replacement: join(__dirname, "../client/src")
|
||||
}
|
||||
],
|
||||
dedupe: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"preact",
|
||||
"preact/compat",
|
||||
"preact/hooks"
|
||||
]
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
// Watch workspace packages
|
||||
ignored: ['!**/node_modules/@triliumnext/**'],
|
||||
// Also watch client assets for live reload
|
||||
usePolling: false,
|
||||
interval: 100,
|
||||
binaryInterval: 300
|
||||
},
|
||||
// Watch additional directories for changes
|
||||
fs: {
|
||||
allow: [
|
||||
// Allow access to workspace root
|
||||
'../../../',
|
||||
// Explicitly allow client directory
|
||||
'../../client/src/'
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
// Required for SharedArrayBuffer which is needed by SQLite WASM OPFS VFS
|
||||
// See: https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp"
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core']
|
||||
},
|
||||
worker: {
|
||||
format: "es" as const
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
outDir: join(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: join(__dirname, 'src', 'index.html'),
|
||||
sw: join(__dirname, 'src', 'sw.ts'),
|
||||
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
|
||||
'local-server-worker': join(__dirname, 'src', 'local-server-worker.ts')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunkInfo) => {
|
||||
// Service worker and other workers should be at root level
|
||||
if (chunkInfo.name === 'sw' || chunkInfo.name === 'local-server-worker') {
|
||||
return '[name].js';
|
||||
}
|
||||
return 'src/[name].js';
|
||||
},
|
||||
chunkFileNames: "src/[name].js",
|
||||
assetFileNames: "src/[name].[ext]"
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom"
|
||||
},
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
}
|
||||
}));
|
||||
@@ -43,11 +43,11 @@
|
||||
"debounce": "3.0.0",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "16.5.0",
|
||||
"globals": "17.0.0",
|
||||
"i18next": "25.7.3",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "3.7.1",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.27",
|
||||
"knockout": "3.5.1",
|
||||
@@ -56,11 +56,12 @@
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.1",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.3.8",
|
||||
"mind-elixir": "5.4.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.1",
|
||||
"react-i18next": "16.5.0",
|
||||
"react-i18next": "16.5.1",
|
||||
"react-window": "2.2.3",
|
||||
"reveal.js": "5.2.1",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"tabulator-tables": "6.3.1",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ColumnComponent } from "tabulator-tables";
|
||||
|
||||
import type { Attribute } from "../services/attribute_parser.js";
|
||||
import froca from "../services/froca.js";
|
||||
import { initLocale,t } from "../services/i18n.js";
|
||||
import { initLocale, t } from "../services/i18n.js";
|
||||
import keyboardActionsService from "../services/keyboard_actions.js";
|
||||
import linkService, { type ViewScope } from "../services/link.js";
|
||||
import type LoadResults from "../services/load_results.js";
|
||||
@@ -382,7 +382,8 @@ export type CommandMappings = {
|
||||
reloadTextEditor: CommandData;
|
||||
chooseNoteType: CommandData & {
|
||||
callback: ChooseNoteTypeCallback
|
||||
}
|
||||
};
|
||||
customDownload: CommandData;
|
||||
};
|
||||
|
||||
type EventMappings = {
|
||||
@@ -473,6 +474,11 @@ type EventMappings = {
|
||||
noteContextRemoved: {
|
||||
ntxIds: string[];
|
||||
};
|
||||
contextDataChanged: {
|
||||
noteContext: NoteContext;
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
exportSvg: { ntxId: string | null | undefined; };
|
||||
exportPng: { ntxId: string | null | undefined; };
|
||||
geoMapCreateChildNote: {
|
||||
|
||||
@@ -57,6 +57,18 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a child component from this component's children array.
|
||||
* This is used for cleanup when a widget is unmounted to prevent event listener accumulation.
|
||||
*/
|
||||
removeChild(component: ChildT) {
|
||||
const index = this.children.indexOf(component);
|
||||
if (index !== -1) {
|
||||
this.children.splice(index, 1);
|
||||
component.parent = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null | undefined {
|
||||
try {
|
||||
const callMethodPromise = this.initialized ? this.initialized.then(() => this.callMethod((this as any)[`${name}Event`], data)) : this.callMethod((this as any)[`${name}Event`], data);
|
||||
|
||||
@@ -12,6 +12,7 @@ import server from "../services/server.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import utils from "../services/utils.js";
|
||||
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
import type { HeadingContext } from "../widgets/sidebar/TableOfContents.js";
|
||||
import appContext, { type EventData, type EventListener } from "./app_context.js";
|
||||
import Component from "./component.js";
|
||||
|
||||
@@ -22,6 +23,31 @@ export interface SetNoteOpts {
|
||||
|
||||
export type GetTextEditorCallback = (editor: CKTextEditor) => void;
|
||||
|
||||
export type SaveState = "saved" | "saving" | "unsaved" | "error";
|
||||
|
||||
export interface NoteContextDataMap {
|
||||
toc: HeadingContext;
|
||||
pdfPages: {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
scrollToPage(page: number): void;
|
||||
requestThumbnail(page: number): void;
|
||||
};
|
||||
pdfAttachments: {
|
||||
attachments: PdfAttachment[];
|
||||
downloadAttachment(filename: string): void;
|
||||
};
|
||||
pdfLayers: {
|
||||
layers: PdfLayer[];
|
||||
toggleLayer(layerId: string, visible: boolean): void;
|
||||
};
|
||||
saveState: {
|
||||
state: SaveState;
|
||||
};
|
||||
}
|
||||
|
||||
type ContextDataKey = keyof NoteContextDataMap;
|
||||
|
||||
class NoteContext extends Component implements EventListener<"entitiesReloaded"> {
|
||||
ntxId: string | null;
|
||||
hoistedNoteId: string;
|
||||
@@ -32,6 +58,13 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
parentNoteId?: string | null;
|
||||
viewScope?: ViewScope;
|
||||
|
||||
/**
|
||||
* Metadata storage for UI components (e.g., table of contents, PDF page list, code outline).
|
||||
* This allows type widgets to publish data that sidebar/toolbar components can consume.
|
||||
* Data is automatically cleared when navigating to a different note.
|
||||
*/
|
||||
private contextData: Map<string, unknown> = new Map();
|
||||
|
||||
constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) {
|
||||
super();
|
||||
|
||||
@@ -91,6 +124,22 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
this.viewScope = opts.viewScope;
|
||||
({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath));
|
||||
|
||||
// Clear context data when switching notes and notify subscribers
|
||||
const oldKeys = Array.from(this.contextData.keys());
|
||||
this.contextData.clear();
|
||||
if (oldKeys.length > 0) {
|
||||
// Notify subscribers asynchronously to avoid blocking navigation
|
||||
window.setTimeout(() => {
|
||||
for (const key of oldKeys) {
|
||||
this.triggerEvent("contextDataChanged", {
|
||||
noteContext: this,
|
||||
key,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
this.saveToRecentNotes(resolvedNotePath);
|
||||
|
||||
protectedSessionHolder.touchProtectedSessionIfNecessary(this.note);
|
||||
@@ -443,6 +492,52 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metadata for this note context (e.g., table of contents, PDF pages, code outline).
|
||||
* This data can be consumed by sidebar/toolbar components.
|
||||
*
|
||||
* @param key - Unique identifier for the data type (e.g., "toc", "pdfPages", "codeOutline")
|
||||
* @param value - The data to store (will be cleared when switching notes)
|
||||
*/
|
||||
setContextData<K extends ContextDataKey>(key: K, value: NoteContextDataMap[K]): void {
|
||||
this.contextData.set(key, value);
|
||||
// Trigger event so subscribers can react
|
||||
this.triggerEvent("contextDataChanged", {
|
||||
noteContext: this,
|
||||
key,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for this note context.
|
||||
*
|
||||
* @param key - The data key to retrieve
|
||||
* @returns The stored data, or undefined if not found
|
||||
*/
|
||||
getContextData<K extends ContextDataKey>(key: K): NoteContextDataMap[K] | undefined {
|
||||
return this.contextData.get(key) as NoteContextDataMap[K] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context data exists for a given key.
|
||||
*/
|
||||
hasContextData(key: ContextDataKey): boolean {
|
||||
return this.contextData.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific context data.
|
||||
*/
|
||||
clearContextData(key: ContextDataKey): void {
|
||||
this.contextData.delete(key);
|
||||
this.triggerEvent("contextDataChanged", {
|
||||
noteContext: this,
|
||||
key,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function openInCurrentNoteContext(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, notePath: string, viewScope?: ViewScope) {
|
||||
|
||||
115
apps/client/src/desktop.html
Normal file
115
apps/client/src/desktop.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<!-- <link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest"> -->
|
||||
<title>Trilium Notes</title>
|
||||
</head>
|
||||
|
||||
<body id="trilium-app">
|
||||
<noscript><%= t("javascript-required") %></noscript>
|
||||
|
||||
<script>
|
||||
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
|
||||
document.getElementsByTagName("body")[0].style.display = "none";
|
||||
</script>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
|
||||
|
||||
<!-- Required for match the PWA's top bar color with the theme -->
|
||||
<!-- This works even when the user directly changes --root-background in CSS -->
|
||||
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
|
||||
|
||||
<!-- Bootstrap (request server for required information) -->
|
||||
<script>
|
||||
async function bootstrap() {
|
||||
await setupGlob();
|
||||
loadStylesheets();
|
||||
loadIcons();
|
||||
setBodyAttributes();
|
||||
await loadScripts();
|
||||
}
|
||||
|
||||
async function setupGlob() {
|
||||
const response = await fetch("/bootstrap");
|
||||
console.log("Service worker state", navigator.serviceWorker.controller);
|
||||
console.log("Resp", response);
|
||||
const json = await response.json();
|
||||
console.log("Bootstrap", json);
|
||||
|
||||
global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
window.glob = {
|
||||
...json,
|
||||
activeDialog: null
|
||||
};
|
||||
}
|
||||
|
||||
function loadStylesheets() {
|
||||
const { assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
|
||||
const cssToLoad = [];
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
|
||||
if (themeCssUrl) {
|
||||
cssToLoad.push(themeCssUrl);
|
||||
}
|
||||
if (themeUseNextAsBase === "next") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`)
|
||||
} else if (themeUseNextAsBase === "next-dark") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`)
|
||||
} else if (themeUseNextAsBase === "next-light") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`)
|
||||
}
|
||||
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
|
||||
|
||||
for (const href of cssToLoad) {
|
||||
const linkEl = document.createElement("link");
|
||||
linkEl.href = href;
|
||||
linkEl.rel = "stylesheet";
|
||||
document.body.appendChild(linkEl);
|
||||
}
|
||||
}
|
||||
|
||||
function loadIcons() {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerText = window.glob.iconPackCss;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
function setBodyAttributes() {
|
||||
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
|
||||
const classesToSet = [
|
||||
device,
|
||||
`heading-style-${headingStyle}`,
|
||||
`layout-${layoutOrientation}`,
|
||||
`platform-${platform}`,
|
||||
isElectron && "isElectron",
|
||||
hasNativeTitleBar && "native-titlebar",
|
||||
hasBackgroundEffects && "background-effects"
|
||||
].filter(Boolean);
|
||||
|
||||
for (const classToSet of classesToSet) {
|
||||
document.body.classList.add(classToSet);
|
||||
}
|
||||
|
||||
document.body.lang = currentLocale.id;
|
||||
document.body.dir = currentLocale.rtl ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
const assetPath = glob.assetPath;
|
||||
await import(`./${assetPath}/runtime.js`);
|
||||
await import(`./${assetPath}/desktop.js`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
</script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,17 +1,18 @@
|
||||
import appContext from "./components/app_context.js";
|
||||
import utils from "./services/utils.js";
|
||||
import noteTooltipService from "./services/note_tooltip.js";
|
||||
import bundleService from "./services/bundle.js";
|
||||
import toastService from "./services/toast.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
import electronContextMenu from "./menus/electron_context_menu.js";
|
||||
import glob from "./services/glob.js";
|
||||
import { t } from "./services/i18n.js";
|
||||
import options from "./services/options.js";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
import appContext from "./components/app_context.js";
|
||||
import electronContextMenu from "./menus/electron_context_menu.js";
|
||||
import bundleService from "./services/bundle.js";
|
||||
import glob from "./services/glob.js";
|
||||
import { t } from "./services/i18n.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
import noteTooltipService from "./services/note_tooltip.js";
|
||||
import options from "./services/options.js";
|
||||
import toastService from "./services/toast.js";
|
||||
import utils from "./services/utils.js";
|
||||
|
||||
await appContext.earlyInit();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import search from "../services/search.js";
|
||||
import server from "../services/server.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type FAttachment from "./fattachment.js";
|
||||
import type { AttributeType,default as FAttribute } from "./fattribute.js";
|
||||
import type { AttributeType, default as FAttribute } from "./fattribute.js";
|
||||
|
||||
const LABEL = "label";
|
||||
const RELATION = "relation";
|
||||
@@ -582,6 +582,10 @@ export default class FNote {
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return `tn-icon ${this.#getIconInternal()}`;
|
||||
}
|
||||
|
||||
#getIconInternal() {
|
||||
const iconClassLabels = this.getLabels("iconClass");
|
||||
const workspaceIconClass = this.getWorkspaceIconClass();
|
||||
|
||||
|
||||
BIN
apps/client/src/fonts/boxicons.woff2
Normal file
BIN
apps/client/src/fonts/boxicons.woff2
Normal file
Binary file not shown.
@@ -1,35 +1,35 @@
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.js";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import type AppContext from "../components/app_context.js";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.js";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import ContentHeader from "../widgets/containers/content_header.js";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import SearchDefinitionTab from "../widgets/ribbon/SearchDefinitionTab.jsx";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import SharedInfoWidget from "../widgets/shared_info.js";
|
||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
|
||||
import type AppContext from "../components/app_context.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
|
||||
const MOBILE_CSS = `
|
||||
<style>
|
||||
@@ -194,11 +194,11 @@ export default class MobileLayout {
|
||||
}
|
||||
|
||||
function FilePropertiesWrapper() {
|
||||
const { note } = useNoteContext();
|
||||
const { note, ntxId } = useNoteContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{note?.type === "file" && <FilePropertiesTab note={note} />}
|
||||
{note?.type === "file" && <FilePropertiesTab note={note} ntxId={ntxId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import appContext from "./components/app_context.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
import glob from "./services/glob.js";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
import appContext from "./components/app_context.js";
|
||||
import glob from "./services/glob.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
|
||||
glob.setupGlobs();
|
||||
|
||||
await appContext.earlyInit();
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import renderService from "./render.js";
|
||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
|
||||
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||
|
||||
import FAttachment from "../entities/fattachment.js";
|
||||
import FNote from "../entities/fnote.js";
|
||||
import imageContextMenuService from "../menus/image_context_menu.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import renderText from "./content_renderer_text.js";
|
||||
import renderDoc from "./doc_renderer.js";
|
||||
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
|
||||
import openService from "./open.js";
|
||||
import protectedSessionService from "./protected_session.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
import openService from "./open.js";
|
||||
import utils from "./utils.js";
|
||||
import FNote from "../entities/fnote.js";
|
||||
import FAttachment from "../entities/fattachment.js";
|
||||
import imageContextMenuService from "../menus/image_context_menu.js";
|
||||
import renderService from "./render.js";
|
||||
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
|
||||
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
|
||||
import renderDoc from "./doc_renderer.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
|
||||
import renderText from "./content_renderer_text.js";
|
||||
import utils from "./utils.js";
|
||||
|
||||
let idCounter = 1;
|
||||
|
||||
@@ -152,7 +153,7 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
|
||||
|
||||
const $img = $("<img>")
|
||||
.attr("src", url || "")
|
||||
.attr("id", "attachment-image-" + idCounter++)
|
||||
.attr("id", `attachment-image-${idCounter++}`)
|
||||
.css("max-width", "100%");
|
||||
|
||||
$renderedContent.append($img);
|
||||
@@ -193,7 +194,7 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent:
|
||||
|
||||
if (type === "pdf") {
|
||||
const $pdfPreview = $('<iframe class="pdf-preview" style="width: 100%; flex-grow: 100;"></iframe>');
|
||||
$pdfPreview.attr("src", openService.getUrlForDownload(`api/${entityType}/${entityId}/open`));
|
||||
$pdfPreview.attr("src", openService.getUrlForDownload(`pdfjs/web/viewer.html?file=../../api/${entityType}/${entityId}/open`));
|
||||
|
||||
$content.append($pdfPreview);
|
||||
} else if (type === "audio") {
|
||||
@@ -217,28 +218,28 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent:
|
||||
// in attachment list
|
||||
const $downloadButton = $(`
|
||||
<button class="file-download btn btn-primary" type="button">
|
||||
<span class="bx bx-download"></span>
|
||||
<span class="tn-icon bx bx-download"></span>
|
||||
${t("file_properties.download")}
|
||||
</button>
|
||||
`);
|
||||
|
||||
const $openButton = $(`
|
||||
<button class="file-open btn btn-primary" type="button">
|
||||
<span class="bx bx-link-external"></span>
|
||||
<span class="tn-icon bx bx-link-external"></span>
|
||||
${t("file_properties.open")}
|
||||
</button>
|
||||
`);
|
||||
|
||||
$downloadButton.on("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openService.downloadFileNote(entity.noteId)
|
||||
openService.downloadFileNote(entity, null, null);
|
||||
});
|
||||
$openButton.on("click", async (e) => {
|
||||
const iconEl = $openButton.find("> .bx");
|
||||
iconEl.removeClass("bx bx-link-external");
|
||||
iconEl.addClass("bx bx-loader spin");
|
||||
e.stopPropagation();
|
||||
await openService.openNoteExternally(entity.noteId, entity.mime)
|
||||
await openService.openNoteExternally(entity.noteId, entity.mime);
|
||||
iconEl.removeClass("bx bx-loader spin");
|
||||
iconEl.addClass("bx bx-link-external");
|
||||
});
|
||||
@@ -266,7 +267,7 @@ async function renderMermaid(note: FNote | FAttachment, $renderedContent: JQuery
|
||||
|
||||
try {
|
||||
await loadElkIfNeeded(mermaid, content);
|
||||
const { svg } = await mermaid.mermaidAPI.render("in-mermaid-graph-" + idCounter++, content);
|
||||
const { svg } = await mermaid.mermaidAPI.render(`in-mermaid-graph-${idCounter++}`, content);
|
||||
|
||||
$renderedContent.append($(postprocessMermaidSvg(svg)));
|
||||
} catch (e) {
|
||||
|
||||
14
apps/client/src/services/css_class_manager.spec.ts
Normal file
14
apps/client/src/services/css_class_manager.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getReadableTextColor } from "./css_class_manager";
|
||||
|
||||
describe("getReadableTextColor", () => {
|
||||
it("doesn't crash for invalid color", () => {
|
||||
expect(getReadableTextColor("RandomColor")).toBe("#000");
|
||||
});
|
||||
|
||||
it("tolerates different casing", () => {
|
||||
expect(getReadableTextColor("Blue"))
|
||||
.toBe(getReadableTextColor("blue"));
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,22 @@
|
||||
import clsx from "clsx";
|
||||
import {readCssVar} from "../utils/css-var";
|
||||
import Color, { ColorInstance } from "color";
|
||||
|
||||
import {readCssVar} from "../utils/css-var";
|
||||
|
||||
const registeredClasses = new Set<string>();
|
||||
const colorsWithHue = new Set<string>();
|
||||
|
||||
// Read the color lightness limits defined in the theme as CSS variables
|
||||
|
||||
const lightThemeColorMaxLightness = readCssVar(
|
||||
document.documentElement,
|
||||
"tree-item-light-theme-max-color-lightness"
|
||||
).asNumber(70);
|
||||
document.documentElement,
|
||||
"tree-item-light-theme-max-color-lightness"
|
||||
).asNumber(70);
|
||||
|
||||
const darkThemeColorMinLightness = readCssVar(
|
||||
document.documentElement,
|
||||
"tree-item-dark-theme-min-color-lightness"
|
||||
).asNumber(50);
|
||||
document.documentElement,
|
||||
"tree-item-dark-theme-min-color-lightness"
|
||||
).asNumber(50);
|
||||
|
||||
function createClassForColor(colorString: string | null) {
|
||||
if (!colorString?.trim()) return "";
|
||||
@@ -27,7 +28,7 @@ function createClassForColor(colorString: string | null) {
|
||||
|
||||
if (!registeredClasses.has(className)) {
|
||||
const adjustedColor = adjustColorLightness(color, lightThemeColorMaxLightness!,
|
||||
darkThemeColorMinLightness!);
|
||||
darkThemeColorMinLightness!);
|
||||
const hue = getHue(color);
|
||||
|
||||
$("head").append(`<style>
|
||||
@@ -50,7 +51,7 @@ function createClassForColor(colorString: string | null) {
|
||||
|
||||
function parseColor(color: string) {
|
||||
try {
|
||||
return Color(color);
|
||||
return Color(color.toLowerCase());
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
}
|
||||
@@ -84,8 +85,8 @@ function getHue(color: ColorInstance) {
|
||||
}
|
||||
|
||||
export function getReadableTextColor(bgColor: string) {
|
||||
const colorInstance = Color(bgColor);
|
||||
return colorInstance.isLight() ? "#000" : "#fff";
|
||||
const colorInstance = parseColor(bgColor);
|
||||
return !colorInstance || colorInstance?.isLight() ? "#000" : "#fff";
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -12,7 +12,7 @@ const SELECTED_NOTE_PATH_KEY = "data-note-path";
|
||||
const SELECTED_EXTERNAL_LINK_KEY = "data-external-link";
|
||||
|
||||
// To prevent search lag when there are a large number of notes, set a delay based on the number of notes to avoid jitter.
|
||||
const notesCount = await server.get<number>(`autocomplete/notesCount`);
|
||||
const notesCount = 10000; // TODO: Replace with dynamic count from becca once available.
|
||||
let debounceTimeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function getSearchDelay(notesCount: number): number {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import utils from "./utils.js";
|
||||
import Component from "../components/component.js";
|
||||
import FNote from "../entities/fnote.js";
|
||||
import options from "./options.js";
|
||||
import server from "./server.js";
|
||||
import utils from "./utils.js";
|
||||
|
||||
type ExecFunction = (command: string, cb: (err: string, stdout: string, stderror: string) => void) => void;
|
||||
|
||||
@@ -36,9 +38,14 @@ function download(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadFileNote(noteId: string) {
|
||||
const url = `${getFileUrl("notes", noteId)}?${Date.now()}`; // don't use cache
|
||||
export function downloadFileNote(note: FNote, parentComponent: Component | null, ntxId: string | null | undefined) {
|
||||
if (note.type === "file" && note.mime === "application/pdf" && parentComponent) {
|
||||
// Special handling, manages its own downloading process.
|
||||
parentComponent.triggerEvent("customDownload", { ntxId });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${getFileUrl("notes", note.noteId)}?${Date.now()}`; // don't use cache
|
||||
download(url);
|
||||
}
|
||||
|
||||
@@ -97,7 +104,7 @@ async function openCustom(type: string, entityId: string, mime: string) {
|
||||
// Note that the path separator must be \ instead of /
|
||||
filePath = filePath.replace(/\//g, "\\");
|
||||
}
|
||||
const command = `rundll32.exe shell32.dll,OpenAs_RunDLL ` + filePath;
|
||||
const command = `rundll32.exe shell32.dll,OpenAs_RunDLL ${filePath}`;
|
||||
exec(command, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error("Open Note custom: ", err);
|
||||
@@ -131,10 +138,10 @@ export function getUrlForDownload(url: string) {
|
||||
if (utils.isElectron()) {
|
||||
// electron needs absolute URL, so we extract current host, port, protocol
|
||||
return `${getHost()}/${url}`;
|
||||
} else {
|
||||
// web server can be deployed on subdomain, so we need to use a relative path
|
||||
return url;
|
||||
}
|
||||
// web server can be deployed on subdomain, so we need to use a relative path
|
||||
return url;
|
||||
|
||||
}
|
||||
|
||||
function canOpenInBrowser(mime: string) {
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
isPromoted?: boolean;
|
||||
labelType?: LabelType;
|
||||
multiplicity?: Multiplicity;
|
||||
numberPrecision?: number;
|
||||
promotedAlias?: string;
|
||||
inverseRelation?: string;
|
||||
}
|
||||
import { DefinitionObject, LabelType, Multiplicity } from "@triliumnext/commons";
|
||||
|
||||
function parse(value: string) {
|
||||
const tokens = value.split(",").map((t) => t.trim());
|
||||
|
||||
@@ -85,13 +85,15 @@ async function remove<T>(url: string, componentId?: string) {
|
||||
return await call<T>("DELETE", url, componentId);
|
||||
}
|
||||
|
||||
async function upload(url: string, fileToUpload: File) {
|
||||
async function upload(url: string, fileToUpload: File, componentId?: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("upload", fileToUpload);
|
||||
|
||||
return await $.ajax({
|
||||
url: window.glob.baseApiUrl + url,
|
||||
headers: await getHeaders(),
|
||||
headers: await getHeaders(componentId ? {
|
||||
"trilium-component-id": componentId
|
||||
} : undefined),
|
||||
data: formData,
|
||||
type: "PUT",
|
||||
timeout: 60 * 60 * 1000,
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import type { SaveState } from "../components/note_context";
|
||||
import { getErrorMessage } from "./utils";
|
||||
|
||||
type Callback = () => Promise<void> | void;
|
||||
|
||||
export type StateCallback = (state: SaveState) => void;
|
||||
|
||||
export default class SpacedUpdate {
|
||||
private updater: Callback;
|
||||
private lastUpdated: number;
|
||||
private changed: boolean;
|
||||
private updateInterval: number;
|
||||
private changeForbidden?: boolean;
|
||||
private stateCallback?: StateCallback;
|
||||
|
||||
constructor(updater: Callback, updateInterval = 1000) {
|
||||
constructor(updater: Callback, updateInterval = 1000, stateCallback?: StateCallback) {
|
||||
this.updater = updater;
|
||||
this.lastUpdated = Date.now();
|
||||
this.changed = false;
|
||||
this.updateInterval = updateInterval;
|
||||
this.stateCallback = stateCallback;
|
||||
}
|
||||
|
||||
scheduleUpdate() {
|
||||
if (!this.changeForbidden) {
|
||||
this.changed = true;
|
||||
this.stateCallback?.("unsaved");
|
||||
setTimeout(() => this.triggerUpdate());
|
||||
}
|
||||
}
|
||||
@@ -26,10 +34,13 @@ export default class SpacedUpdate {
|
||||
this.changed = false; // optimistic...
|
||||
|
||||
try {
|
||||
this.stateCallback?.("saving");
|
||||
await this.updater();
|
||||
this.stateCallback?.("saved");
|
||||
} catch (e) {
|
||||
this.changed = true;
|
||||
|
||||
this.stateCallback?.("error");
|
||||
logError(getErrorMessage(e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -59,15 +70,22 @@ export default class SpacedUpdate {
|
||||
this.updateInterval = interval;
|
||||
}
|
||||
|
||||
triggerUpdate() {
|
||||
async triggerUpdate() {
|
||||
if (!this.changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - this.lastUpdated > this.updateInterval) {
|
||||
this.updater();
|
||||
this.stateCallback?.("saving");
|
||||
try {
|
||||
await this.updater();
|
||||
this.stateCallback?.("saved");
|
||||
this.changed = false;
|
||||
} catch (e) {
|
||||
this.stateCallback?.("error");
|
||||
logError(getErrorMessage(e));
|
||||
}
|
||||
this.lastUpdated = Date.now();
|
||||
this.changed = false;
|
||||
} else {
|
||||
// update isn't triggered but changes are still pending, so we need to schedule another check
|
||||
this.scheduleUpdate();
|
||||
|
||||
@@ -133,6 +133,8 @@ export function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
export const isStandalone = window.glob.isStandalone;
|
||||
|
||||
/**
|
||||
* Returns `true` if the client is running as a PWA, otherwise `false`.
|
||||
*/
|
||||
@@ -187,13 +189,15 @@ export function formatSize(size: number | null | undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
size = Math.max(Math.round(size / 1024), 1);
|
||||
|
||||
if (size < 1024) {
|
||||
return `${size} KiB`;
|
||||
if (size === 0) {
|
||||
return "0 B";
|
||||
}
|
||||
return `${Math.round(size / 102.4) / 10} MiB`;
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KiB", "MiB", "GiB"];
|
||||
const i = Math.floor(Math.log(size) / Math.log(k));
|
||||
|
||||
return `${Math.round((size / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function toObject<T, R>(array: T[], fn: (arg0: T) => [key: string, value: R]) {
|
||||
@@ -812,7 +816,7 @@ function compareVersions(v1: string, v2: string): number {
|
||||
/**
|
||||
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
|
||||
*/
|
||||
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
export function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
498
apps/client/src/stylesheets/boxicons-compat.css
Normal file
498
apps/client/src/stylesheets/boxicons-compat.css
Normal file
@@ -0,0 +1,498 @@
|
||||
.bx-ul
|
||||
{
|
||||
margin-left: 2em;
|
||||
padding-left: 0;
|
||||
|
||||
list-style: none;
|
||||
}
|
||||
.bx-ul > li
|
||||
{
|
||||
position: relative;
|
||||
}
|
||||
.bx-ul .bx
|
||||
{
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
|
||||
position: absolute;
|
||||
left: -2em;
|
||||
|
||||
width: 2em;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
@-webkit-keyframes spin
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: rotate(0);
|
||||
transform: rotate(0);
|
||||
}
|
||||
100%
|
||||
{
|
||||
-webkit-transform: rotate(359deg);
|
||||
transform: rotate(359deg);
|
||||
}
|
||||
}
|
||||
@keyframes spin
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: rotate(0);
|
||||
transform: rotate(0);
|
||||
}
|
||||
100%
|
||||
{
|
||||
-webkit-transform: rotate(359deg);
|
||||
transform: rotate(359deg);
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes burst
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
90%
|
||||
{
|
||||
-webkit-transform: scale(1.5);
|
||||
transform: scale(1.5);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes burst
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
90%
|
||||
{
|
||||
-webkit-transform: scale(1.5);
|
||||
transform: scale(1.5);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes flashing
|
||||
{
|
||||
0%
|
||||
{
|
||||
opacity: 1;
|
||||
}
|
||||
45%
|
||||
{
|
||||
opacity: 0;
|
||||
}
|
||||
90%
|
||||
{
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes flashing
|
||||
{
|
||||
0%
|
||||
{
|
||||
opacity: 1;
|
||||
}
|
||||
45%
|
||||
{
|
||||
opacity: 0;
|
||||
}
|
||||
90%
|
||||
{
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes fade-left
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: translateX(0);
|
||||
transform: translateX(0);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
75%
|
||||
{
|
||||
-webkit-transform: translateX(-20px);
|
||||
transform: translateX(-20px);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fade-left
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: translateX(0);
|
||||
transform: translateX(0);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
75%
|
||||
{
|
||||
-webkit-transform: translateX(-20px);
|
||||
transform: translateX(-20px);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes fade-right
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: translateX(0);
|
||||
transform: translateX(0);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
75%
|
||||
{
|
||||
-webkit-transform: translateX(20px);
|
||||
transform: translateX(20px);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fade-right
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: translateX(0);
|
||||
transform: translateX(0);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
75%
|
||||
{
|
||||
-webkit-transform: translateX(20px);
|
||||
transform: translateX(20px);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes fade-up
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: translateY(0);
|
||||
transform: translateY(0);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
75%
|
||||
{
|
||||
-webkit-transform: translateY(-20px);
|
||||
transform: translateY(-20px);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fade-up
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: translateY(0);
|
||||
transform: translateY(0);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
75%
|
||||
{
|
||||
-webkit-transform: translateY(-20px);
|
||||
transform: translateY(-20px);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes fade-down
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: translateY(0);
|
||||
transform: translateY(0);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
75%
|
||||
{
|
||||
-webkit-transform: translateY(20px);
|
||||
transform: translateY(20px);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fade-down
|
||||
{
|
||||
0%
|
||||
{
|
||||
-webkit-transform: translateY(0);
|
||||
transform: translateY(0);
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
75%
|
||||
{
|
||||
-webkit-transform: translateY(20px);
|
||||
transform: translateY(20px);
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes tada
|
||||
{
|
||||
from
|
||||
{
|
||||
-webkit-transform: scale3d(1, 1, 1);
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
10%,
|
||||
20%
|
||||
{
|
||||
-webkit-transform: scale3d(.95, .95, .95) rotate3d(0, 0, 1, -10deg);
|
||||
transform: scale3d(.95, .95, .95) rotate3d(0, 0, 1, -10deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90%
|
||||
{
|
||||
-webkit-transform: scale3d(1, 1, 1) rotate3d(0, 0, 1, 10deg);
|
||||
transform: scale3d(1, 1, 1) rotate3d(0, 0, 1, 10deg);
|
||||
}
|
||||
|
||||
40%,
|
||||
60%,
|
||||
80%
|
||||
{
|
||||
-webkit-transform: scale3d(1, 1, 1) rotate3d(0, 0, 1, -10deg);
|
||||
transform: scale3d(1, 1, 1) rotate3d(0, 0, 1, -10deg);
|
||||
}
|
||||
|
||||
to
|
||||
{
|
||||
-webkit-transform: scale3d(1, 1, 1);
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tada
|
||||
{
|
||||
from
|
||||
{
|
||||
-webkit-transform: scale3d(1, 1, 1);
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
10%,
|
||||
20%
|
||||
{
|
||||
-webkit-transform: scale3d(.95, .95, .95) rotate3d(0, 0, 1, -10deg);
|
||||
transform: scale3d(.95, .95, .95) rotate3d(0, 0, 1, -10deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90%
|
||||
{
|
||||
-webkit-transform: scale3d(1, 1, 1) rotate3d(0, 0, 1, 10deg);
|
||||
transform: scale3d(1, 1, 1) rotate3d(0, 0, 1, 10deg);
|
||||
}
|
||||
|
||||
40%,
|
||||
60%,
|
||||
80%
|
||||
{
|
||||
-webkit-transform: rotate3d(0, 0, 1, -10deg);
|
||||
transform: rotate3d(0, 0, 1, -10deg);
|
||||
}
|
||||
|
||||
to
|
||||
{
|
||||
-webkit-transform: scale3d(1, 1, 1);
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
.bx-spin
|
||||
{
|
||||
-webkit-animation: spin 2s linear infinite;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
.bx-spin-hover:hover
|
||||
{
|
||||
-webkit-animation: spin 2s linear infinite;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.bx-tada
|
||||
{
|
||||
-webkit-animation: tada 1.5s ease infinite;
|
||||
animation: tada 1.5s ease infinite;
|
||||
}
|
||||
.bx-tada-hover:hover
|
||||
{
|
||||
-webkit-animation: tada 1.5s ease infinite;
|
||||
animation: tada 1.5s ease infinite;
|
||||
}
|
||||
|
||||
.bx-flashing
|
||||
{
|
||||
-webkit-animation: flashing 1.5s infinite linear;
|
||||
animation: flashing 1.5s infinite linear;
|
||||
}
|
||||
.bx-flashing-hover:hover
|
||||
{
|
||||
-webkit-animation: flashing 1.5s infinite linear;
|
||||
animation: flashing 1.5s infinite linear;
|
||||
}
|
||||
|
||||
.bx-burst
|
||||
{
|
||||
-webkit-animation: burst 1.5s infinite linear;
|
||||
animation: burst 1.5s infinite linear;
|
||||
}
|
||||
.bx-burst-hover:hover
|
||||
{
|
||||
-webkit-animation: burst 1.5s infinite linear;
|
||||
animation: burst 1.5s infinite linear;
|
||||
}
|
||||
.bx-fade-up
|
||||
{
|
||||
-webkit-animation: fade-up 1.5s infinite linear;
|
||||
animation: fade-up 1.5s infinite linear;
|
||||
}
|
||||
.bx-fade-up-hover:hover
|
||||
{
|
||||
-webkit-animation: fade-up 1.5s infinite linear;
|
||||
animation: fade-up 1.5s infinite linear;
|
||||
}
|
||||
.bx-fade-down
|
||||
{
|
||||
-webkit-animation: fade-down 1.5s infinite linear;
|
||||
animation: fade-down 1.5s infinite linear;
|
||||
}
|
||||
.bx-fade-down-hover:hover
|
||||
{
|
||||
-webkit-animation: fade-down 1.5s infinite linear;
|
||||
animation: fade-down 1.5s infinite linear;
|
||||
}
|
||||
.bx-fade-left
|
||||
{
|
||||
-webkit-animation: fade-left 1.5s infinite linear;
|
||||
animation: fade-left 1.5s infinite linear;
|
||||
}
|
||||
.bx-fade-left-hover:hover
|
||||
{
|
||||
-webkit-animation: fade-left 1.5s infinite linear;
|
||||
animation: fade-left 1.5s infinite linear;
|
||||
}
|
||||
.bx-fade-right
|
||||
{
|
||||
-webkit-animation: fade-right 1.5s infinite linear;
|
||||
animation: fade-right 1.5s infinite linear;
|
||||
}
|
||||
.bx-fade-right-hover:hover
|
||||
{
|
||||
-webkit-animation: fade-right 1.5s infinite linear;
|
||||
animation: fade-right 1.5s infinite linear;
|
||||
}
|
||||
.bx-xs
|
||||
{
|
||||
font-size: 1rem!important;
|
||||
}
|
||||
.bx-sm
|
||||
{
|
||||
font-size: 1.55rem!important;
|
||||
}
|
||||
.bx-md
|
||||
{
|
||||
font-size: 2.25rem!important;
|
||||
}
|
||||
.bx-lg
|
||||
{
|
||||
font-size: 3.0rem!important;
|
||||
}
|
||||
.bx-fw
|
||||
{
|
||||
font-size: 1.2857142857em;
|
||||
line-height: .8em;
|
||||
|
||||
width: 1.2857142857em;
|
||||
height: .8em;
|
||||
margin-top: -.2em!important;
|
||||
|
||||
vertical-align: middle;
|
||||
}
|
||||
.bx-pull-left
|
||||
{
|
||||
float: left;
|
||||
|
||||
margin-right: .3em!important;
|
||||
}
|
||||
.bx-pull-right
|
||||
{
|
||||
float: right;
|
||||
|
||||
margin-left: .3em!important;
|
||||
}
|
||||
.bx-rotate-90
|
||||
{
|
||||
transform: rotate(90deg);
|
||||
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.BasicImage(rotation=1)';
|
||||
}
|
||||
.bx-rotate-180
|
||||
{
|
||||
transform: rotate(180deg);
|
||||
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.BasicImage(rotation=2)';
|
||||
}
|
||||
.bx-rotate-270
|
||||
{
|
||||
transform: rotate(270deg);
|
||||
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.BasicImage(rotation=3)';
|
||||
}
|
||||
.bx-flip-horizontal
|
||||
{
|
||||
transform: scaleX(-1);
|
||||
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)';
|
||||
}
|
||||
.bx-flip-vertical
|
||||
{
|
||||
transform: scaleY(-1);
|
||||
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)';
|
||||
}
|
||||
.bx-border
|
||||
{
|
||||
padding: .25em;
|
||||
|
||||
border: .07em solid rgba(0,0,0,.1);
|
||||
border-radius: .25em;
|
||||
}
|
||||
.bx-border-circle
|
||||
{
|
||||
padding: .25em;
|
||||
|
||||
border: .07em solid rgba(0,0,0,.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/** Custom icon **/
|
||||
.bx-empty {
|
||||
width: 1em;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "./boxicons-compat.css";
|
||||
|
||||
@font-face {
|
||||
font-family: Montserrat;
|
||||
src: url(../fonts/Montserrat-Light.ttf);
|
||||
@@ -1128,11 +1130,6 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
|
||||
border-color: var(--main-border-color) !important;
|
||||
}
|
||||
|
||||
.bx-empty {
|
||||
width: 1em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 0.5rem 1rem 0.5rem 1rem !important; /* make modal header padding slightly smaller */
|
||||
}
|
||||
@@ -1799,7 +1796,7 @@ button.close:hover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reference-link .bx {
|
||||
.reference-link .tn-icon {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
margin-inline-end: 3px;
|
||||
@@ -2418,7 +2415,7 @@ footer.webview-footer button {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.right-pane-tab .tab-title .bx {
|
||||
.right-pane-tab .tab-title .tn-icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
@@ -2546,18 +2543,11 @@ footer.webview-footer button {
|
||||
inset-inline-end: 10px;
|
||||
}
|
||||
|
||||
.content-floating-buttons button.bx {
|
||||
.content-floating-buttons button.tn-icon {
|
||||
font-size: 130%;
|
||||
padding: 1px 10px 1px 10px;
|
||||
}
|
||||
|
||||
/* Customized icons */
|
||||
|
||||
.bx-tn-toc::before {
|
||||
content: "\ec24";
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* CK Editor */
|
||||
|
||||
/* Insert text snippet: limit the width of the listed items to avoid overly long names */
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: var(--theme-style);
|
||||
|
||||
--main-font-family: "Inter", sans-serif;
|
||||
|
||||
--main-font-size: normal;
|
||||
@@ -134,7 +136,7 @@ body.backdrop-effects-disabled {
|
||||
white-space-collapse: discard;
|
||||
}
|
||||
|
||||
.dropdown-menu.tn-dropdown-menu .bx {
|
||||
.dropdown-menu.tn-dropdown-menu .dropdown-item .tn-icon {
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
@@ -249,7 +251,7 @@ html body .dropdown-item[disabled] {
|
||||
}
|
||||
|
||||
/* Menu item icon */
|
||||
.dropdown-item .bx {
|
||||
.dropdown-item .tn-icon {
|
||||
translate: 0 var(--menu-item-icon-vert-offset);
|
||||
color: var(--menu-item-icon-color) !important;
|
||||
font-size: 1.1em;
|
||||
@@ -496,7 +498,7 @@ li.dropdown-item a.dropdown-item-button {
|
||||
border: unset;
|
||||
}
|
||||
|
||||
li.dropdown-item a.dropdown-item-button.bx {
|
||||
li.dropdown-item a.dropdown-item-button.tn-icon {
|
||||
color: var(--menu-text-color) !important;
|
||||
}
|
||||
|
||||
@@ -557,13 +559,13 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#toast-container .toast:not(.no-title) .bx {
|
||||
#toast-container .toast:not(.no-title) .tn-icon {
|
||||
margin-inline-end: 0.5em;
|
||||
font-size: 1.1em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
#toast-container .toast.no-title .bx {
|
||||
#toast-container .toast.no-title .tn-icon {
|
||||
margin-inline-end: 0;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
@@ -754,7 +756,7 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.note-list-wrapper .note-book-card .bx {
|
||||
.note-list-wrapper .note-book-card .tn-icon {
|
||||
color: var(--left-pane-icon-color) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -423,6 +423,6 @@ div.tn-tool-dialog {
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
.note-type-chooser-dialog div.note-type-dropdown .dropdown-item span.bx {
|
||||
.note-type-chooser-dialog div.note-type-dropdown .dropdown-item span.tn-icon {
|
||||
margin-inline-end: .25em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,10 +62,10 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .c
|
||||
}
|
||||
|
||||
/* Button's icon */
|
||||
button.btn.btn-primary span.bx,
|
||||
button.btn.btn-secondary span.bx,
|
||||
button.btn.btn-sm span.bx,
|
||||
button.btn.btn-success span.bx {
|
||||
button.btn.btn-primary span.tn-icon,
|
||||
button.btn.btn-secondary span.tn-icon,
|
||||
button.btn.btn-sm span.tn-icon,
|
||||
button.btn.btn-success span.tn-icon {
|
||||
color: var(--cmd-button-icon-color);
|
||||
padding-inline-end: 0.35em;
|
||||
font-size: 1.2em;
|
||||
|
||||
@@ -151,6 +151,11 @@
|
||||
--options-title-font-size: .75rem;
|
||||
--options-title-offset: 13px;
|
||||
}
|
||||
|
||||
.note-split.options {
|
||||
--preferred-max-content-width: var(--options-card-max-width);
|
||||
}
|
||||
|
||||
/* Create a gap at the top of the option pages */
|
||||
.note-detail-content-widget-content.options>*:first-child {
|
||||
margin-top: var(--options-first-item-top-margin, 1em);
|
||||
@@ -185,10 +190,6 @@ body.experimental-feature-new-layout .note-detail-content-widget-content.options
|
||||
padding: var(--options-card-padding);
|
||||
}
|
||||
|
||||
body.prefers-centered-content .options-section:not(.tn-no-card) {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
body.desktop .options-section:not(.tn-no-card) {
|
||||
min-width: var(--options-card-min-width);
|
||||
max-width: var(--options-card-max-width);
|
||||
|
||||
@@ -497,7 +497,7 @@ div.bookmark-folder-widget .note-link:hover a {
|
||||
}
|
||||
|
||||
/* The item's icon */
|
||||
div.bookmark-folder-widget .note-link .bx {
|
||||
div.bookmark-folder-widget .note-link .tn-icon {
|
||||
color: var(--menu-item-icon-color);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
@@ -767,7 +767,7 @@ body.mobile .fancytree-node > span {
|
||||
background: var(--left-pane-item-hover-background);
|
||||
}
|
||||
|
||||
#left-pane span.fancytree-node.shared .fancytree-title::after {
|
||||
#left-pane .note-indicator-icon.shared-indicator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -1259,8 +1259,16 @@ body.layout-horizontal #rest-pane > .classic-toolbar-widget {
|
||||
#center-pane .note-split {
|
||||
padding-top: 2px;
|
||||
background-color: var(--note-split-background-color, var(--main-background-color));
|
||||
transition: border-color 250ms ease-in;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
/* The active split in a multi-split view */
|
||||
#center-pane > .split-note-container-widget:has(> .note-split.visible ~ .note-split.visible) > .note-split.active {
|
||||
border-color: var(--link-selection-outline-color);
|
||||
}
|
||||
|
||||
|
||||
body:not(.background-effects) #center-pane .note-split {
|
||||
animation: note-entrance 100ms linear;
|
||||
}
|
||||
|
||||
@@ -148,29 +148,28 @@ span.fancytree-node.protected > span.fancytree-custom-icon {
|
||||
filter: drop-shadow(2px 2px 2px var(--main-text-color));
|
||||
}
|
||||
|
||||
span.fancytree-node.multiple-parents.shared .fancytree-title::after {
|
||||
/* Note indicator icons (clone, shared) - real DOM elements for tooltip support */
|
||||
.note-indicator-icon {
|
||||
font-family: "boxicons" !important;
|
||||
font-size: smaller;
|
||||
content: " \eb3d \ec03";
|
||||
margin-inline-start: 4px;
|
||||
opacity: 0.8;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
span.fancytree-node.multiple-parents .fancytree-title::after {
|
||||
font-family: "boxicons" !important;
|
||||
font-size: smaller;
|
||||
content: " \eb3d"; /* lookup code for "link-alt" in boxicons.css */
|
||||
.note-indicator-icon.clone-indicator::before {
|
||||
content: "\eb3d"; /* bx-link-alt */
|
||||
}
|
||||
|
||||
body.experimental-feature-new-layout span.fancytree-node.multiple-parents .fancytree-title::after {
|
||||
content: " \ed82";
|
||||
.note-indicator-icon.shared-indicator::before {
|
||||
content: "\ec03"; /* bx-share-alt */
|
||||
}
|
||||
|
||||
body.experimental-feature-new-layout .note-indicator-icon.clone-indicator::before {
|
||||
content: "\ed82";
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
span.fancytree-node.shared .fancytree-title::after {
|
||||
font-family: "boxicons" !important;
|
||||
font-size: smaller;
|
||||
content: " \ec03"; /* lookup code for "share-alt" in boxicons.css */
|
||||
}
|
||||
|
||||
span.fancytree-node.fancytree-active-clone:not(.fancytree-active) .fancytree-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -229,11 +228,11 @@ span.fancytree-node.archived {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.fancytree-node:hover .bx.tree-item-button {
|
||||
.fancytree-node:hover .tn-icon.tree-item-button {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.bx.tree-item-button {
|
||||
.tn-icon.tree-item-button {
|
||||
display: none;
|
||||
font-size: 120%;
|
||||
cursor: pointer;
|
||||
@@ -243,7 +242,7 @@ span.fancytree-node.archived {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.unhoist-button.bx.tree-item-button {
|
||||
.unhoist-button.tn-icon.tree-item-button {
|
||||
margin-inline-start: 0; /* unhoist button is on the left and doesn't need more margin */
|
||||
display: block; /* keep always visible */
|
||||
}
|
||||
|
||||
@@ -223,7 +223,6 @@
|
||||
"backlink_other": ""
|
||||
},
|
||||
"note_icon": {
|
||||
"category": "الفئة:",
|
||||
"search": "بحث:",
|
||||
"change_note_icon": "تغيير ايقونة الملاحظة",
|
||||
"reset-default": "اعادة تعيين الى الايقونة الافتراضية"
|
||||
@@ -485,7 +484,6 @@
|
||||
"delete_button": "حذف",
|
||||
"download_button": "تنزيل",
|
||||
"restore_button": "أستعادة",
|
||||
"preview": "معاينة:",
|
||||
"note_revisions": "مراجعات الملاحظة",
|
||||
"diff_on": "عرض الفروقات",
|
||||
"diff_off": "عرض المحتوى",
|
||||
|
||||
@@ -64,8 +64,7 @@
|
||||
"restore_button": "Restaura",
|
||||
"delete_button": "Suprimeix",
|
||||
"download_button": "Descarrega",
|
||||
"mime": "MIME: ",
|
||||
"preview": "Vista prèvia:"
|
||||
"mime": "MIME: "
|
||||
},
|
||||
"sort_child_notes": {
|
||||
"title": "títol",
|
||||
@@ -146,7 +145,6 @@
|
||||
"relation": "relació"
|
||||
},
|
||||
"note_icon": {
|
||||
"category": "Categoria:",
|
||||
"search": "Cerca:"
|
||||
},
|
||||
"basic_properties": {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"widget-render-error": {
|
||||
"title": "渲染自定义 React 小部件失败"
|
||||
},
|
||||
"widget-missing-parent": "自定义小部件未定义强制性的 \"{{property}}\" 属性。",
|
||||
"widget-missing-parent": "自定义小部件未定义强制性的 \"{{property}}\" 属性。\n\n如果此脚本需要在没有 UI 元素的情况下运行,请改用“#run=frontendStartup”。",
|
||||
"open-script-note": "打开脚本笔记",
|
||||
"scripting-error": "自定义脚本错误:{{title}}"
|
||||
},
|
||||
@@ -290,7 +290,6 @@
|
||||
"download_button": "下载",
|
||||
"mime": "MIME 类型: ",
|
||||
"file_size": "文件大小:",
|
||||
"preview": "预览:",
|
||||
"preview_not_available": "无法预览此类型的笔记。",
|
||||
"diff_on": "显示差异",
|
||||
"diff_off": "显示内容",
|
||||
@@ -764,9 +763,15 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "更改笔记图标",
|
||||
"category": "类别:",
|
||||
"search": "搜索:",
|
||||
"reset-default": "重置为默认图标"
|
||||
"reset-default": "重置为默认图标",
|
||||
"search_placeholder_other": "在 {{count}} 个图标包中搜索 {{number}} 个图标",
|
||||
"search_placeholder_filtered": "在 {{name}} 中搜索 {{number}} 个图标",
|
||||
"filter": "筛选",
|
||||
"filter-none": "所有图标",
|
||||
"filter-default": "默认图标",
|
||||
"icon_tooltip": "{{name}}\n图标包:{{iconPack}}",
|
||||
"no_results": "没有找到图标。"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "笔记类型",
|
||||
@@ -1446,7 +1451,7 @@
|
||||
"will_be_deleted_in": "此附件将在 {{time}} 后自动删除",
|
||||
"will_be_deleted_soon": "该附件在不久后将被自动删除",
|
||||
"deletion_reason": ",因为该附件未链接在笔记的内容中。为防止被删除,请将附件链接重新添加到内容中或将附件转换为笔记。",
|
||||
"role_and_size": "角色:{{role}},大小:{{size}}",
|
||||
"role_and_size": "角色:{{role}},大小:{{size}},文件类型:{{- mimeType}}",
|
||||
"link_copied": "附件链接已复制到剪贴板。",
|
||||
"unrecognized_role": "无法识别的附件角色 '{{role}}'。"
|
||||
},
|
||||
@@ -1589,7 +1594,11 @@
|
||||
"create-child-note": "创建子笔记",
|
||||
"unhoist": "取消聚焦",
|
||||
"toggle-sidebar": "切换侧边栏",
|
||||
"dropping-not-allowed": "不允许移动笔记到此处。"
|
||||
"dropping-not-allowed": "不允许移动笔记到此处。",
|
||||
"shared-indicator-tooltip": "此笔记已公开分享",
|
||||
"shared-indicator-tooltip-with-url": "此笔记已公开分享至:{{- url}}",
|
||||
"clone-indicator-tooltip": "此笔记有 {{- count}} 个父级: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "此笔记已克隆(1 个额外的父级:{{- parent}})"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "保持此窗口置顶"
|
||||
@@ -1597,7 +1606,11 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "找不到类型为 '{{type}}' 的 typeWidget",
|
||||
"printing": "正在打印…",
|
||||
"printing_pdf": "正在导出为PDF…"
|
||||
"printing_pdf": "正在导出为PDF…",
|
||||
"print_report_title": "打印报告",
|
||||
"print_report_collection_content_other": "集合中的 {{count}} 篇笔记无法打印,因为它们不受支持或受到保护。",
|
||||
"print_report_collection_details_button": "查看详情",
|
||||
"print_report_collection_details_ignored_notes": "忽略的笔记"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "请输入笔记标题...",
|
||||
@@ -2183,7 +2196,14 @@
|
||||
"execute_sql_description": "这是一篇 SQL 笔记。点击即可执行 SQL 查询。",
|
||||
"shared_copy_to_clipboard": "复制链接到剪贴板",
|
||||
"shared_open_in_browser": "在浏览器中打开链接",
|
||||
"shared_unshare": "取消共享"
|
||||
"shared_unshare": "取消共享",
|
||||
"save_status_saved": "已保存",
|
||||
"save_status_saving": "保存中...",
|
||||
"save_status_unsaved": "未保存",
|
||||
"save_status_error": "保存失败",
|
||||
"save_status_unsaved_tooltip": "还有一些更改尚未保存。它们将稍后自动保存。",
|
||||
"save_status_error_tooltip": "保存笔记时出错。如果可以,请尝试将笔记内容复制到其他位置并重新加载应用程序。",
|
||||
"save_status_saving_tooltip": "更改正在保存。"
|
||||
},
|
||||
"status_bar": {
|
||||
"language_title": "更改内容语言",
|
||||
@@ -2214,5 +2234,12 @@
|
||||
},
|
||||
"attributes_panel": {
|
||||
"title": "笔记属性"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_other": "{{count}} 个附件",
|
||||
"pages_other": "共{{count}}页",
|
||||
"pages_alt": "第{{pageNumber}}页",
|
||||
"pages_loading": "加载中...",
|
||||
"layers_other": "{{count}} 层"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Benutzerdefiniertes Skript konnte nicht geladen werden",
|
||||
"message": "Skript aus der Notiz \"{{title}}\" mit der ID \"{{id}}\", konnte nicht ausgeführt werden wegen:\n\n{{message}}"
|
||||
"message": "Skript konnte nicht ausgeführt werden wegen:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Abruf der Liste von Widgets vom Server ist fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
@@ -278,7 +281,6 @@
|
||||
"download_button": "Herunterladen",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Dateigröße:",
|
||||
"preview": "Vorschau:",
|
||||
"preview_not_available": "Für diesen Notiztyp ist keine Vorschau verfügbar.",
|
||||
"restore_button": "Wiederherstellen",
|
||||
"delete_button": "Löschen",
|
||||
@@ -689,7 +691,11 @@
|
||||
"convert_into_attachment_successful": "Notiz '{{title}}' wurde als Anhang konvertiert.",
|
||||
"convert_into_attachment_prompt": "Bist du dir sicher, dass du die Notiz '{{title}}' in ein Anhang der übergeordneten Notiz konvertieren möchtest?",
|
||||
"print_pdf": "Export als PDF...",
|
||||
"open_note_on_server": "Öffne Notiz auf dem Server"
|
||||
"open_note_on_server": "Öffne Notiz auf dem Server",
|
||||
"export_as_image": "Als Bild exportieren",
|
||||
"export_as_image_png": "PNG (Raster)",
|
||||
"export_as_image_svg": "SVG (Vektor)",
|
||||
"note_map": "Notizen Karte"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "Das Schaltflächen-Widget „{{componentId}}“ hat keinen definierten Klick-Handler"
|
||||
@@ -746,9 +752,16 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Notiz-Icon ändern",
|
||||
"category": "Kategorie:",
|
||||
"search": "Suche:",
|
||||
"reset-default": "Standard wiederherstellen"
|
||||
"reset-default": "Standard wiederherstellen",
|
||||
"search_placeholder_one": "Suche {{number}} Icons über {{count}} Pakete",
|
||||
"search_placeholder_other": "Suche {{number}} Icons über {{count}} Pakete",
|
||||
"search_placeholder_filtered": "Suche {{number}} Icons in {{name}}",
|
||||
"filter": "Filter",
|
||||
"filter-none": "Alle Icons",
|
||||
"filter-default": "Standard Icons",
|
||||
"icon_tooltip": "{{name}}\nIcon Paket: {{iconPack}}",
|
||||
"no_results": "Keine Icons gefunden."
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Notiztyp",
|
||||
@@ -808,7 +821,8 @@
|
||||
},
|
||||
"inherited_attribute_list": {
|
||||
"title": "Geerbte Attribute",
|
||||
"no_inherited_attributes": "Keine geerbten Attribute."
|
||||
"no_inherited_attributes": "Keine geerbten Attribute.",
|
||||
"none": "Keine"
|
||||
},
|
||||
"note_info_widget": {
|
||||
"note_id": "Notiz-ID",
|
||||
@@ -819,7 +833,9 @@
|
||||
"note_size_info": "Die Notizgröße bietet eine grobe Schätzung des Speicherbedarfs für diese Notiz. Es berücksichtigt den Inhalt der Notiz und den Inhalt ihrer Notizrevisionen.",
|
||||
"calculate": "berechnen",
|
||||
"subtree_size": "(Teilbaumgröße: {{size}} in {{count}} Notizen)",
|
||||
"title": "Notizinfo"
|
||||
"title": "Notizinfo",
|
||||
"mime": "MIME Typ",
|
||||
"show_similar_notes": "Zeige ähnliche Notizen"
|
||||
},
|
||||
"note_map": {
|
||||
"open_full": "Vollständig erweitern",
|
||||
@@ -882,7 +898,8 @@
|
||||
"search_parameters": "Suchparameter",
|
||||
"unknown_search_option": "Unbekannte Suchoption {{searchOptionName}}",
|
||||
"search_note_saved": "Suchnotiz wurde in {{-notePathTitle}} gespeichert",
|
||||
"actions_executed": "Aktionen wurden ausgeführt."
|
||||
"actions_executed": "Aktionen wurden ausgeführt.",
|
||||
"view_options": "Anzeigeoptionen:"
|
||||
},
|
||||
"similar_notes": {
|
||||
"title": "Ähnliche Notizen",
|
||||
@@ -986,7 +1003,12 @@
|
||||
"editable_text": {
|
||||
"placeholder": "Gebe hier den Inhalt deiner Notiz ein...",
|
||||
"auto-detect-language": "Automatisch erkannt",
|
||||
"keeps-crashing": "Die Bearbeitungskomponente stürzt immer wieder ab. Bitte starten Sie Trilium neu. Wenn das Problem weiterhin besteht, erstellen Sie einen Fehlerbericht."
|
||||
"keeps-crashing": "Die Bearbeitungskomponente stürzt immer wieder ab. Bitte starten Sie Trilium neu. Wenn das Problem weiterhin besteht, erstellen Sie einen Fehlerbericht.",
|
||||
"editor_crashed_title": "Der Text Editor ist abgestürzt",
|
||||
"editor_crashed_content": "Ihr Inhalt wurde erfolgreich wiederhergestellt, aber einzelne Ihrer letzten Änderungen waren möglicherweise noch nicht gespeichert.",
|
||||
"editor_crashed_details_button": "Zeige mehr Details…",
|
||||
"editor_crashed_details_intro": "Falls Sie diesen Fehler mehrmals sehen, melden Sie dies auf GitHub mit den folgenden Informationen.",
|
||||
"editor_crashed_details_title": "Technische Informationen"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Öffne eine Notiz, indem du den Titel der Notiz in die Eingabe unten eingibst oder eine Notiz in der Baumstruktur auswählst.",
|
||||
@@ -1501,7 +1523,12 @@
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Hervorhebungs-Liste",
|
||||
"options": "Optionen"
|
||||
"options": "Optionen",
|
||||
"title_with_count_one": "{{count}} Highlight",
|
||||
"title_with_count_other": "{{count}} Highlights",
|
||||
"modal_title": "Highlight Liste konfigurieren",
|
||||
"menu_configure": "Highlight Liste konfigurieren…",
|
||||
"no_highlights": "Keine Highlights gefunden."
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Schnellsuche",
|
||||
@@ -1533,10 +1560,21 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Konnte typeWidget für Typ ‚{{type}}‘ nicht finden",
|
||||
"printing": "Druckvorgang läuft…",
|
||||
"printing_pdf": "PDF-Export läuft…"
|
||||
"printing_pdf": "PDF-Export läuft…",
|
||||
"print_report_title": "Druckreport",
|
||||
"print_report_collection_details_button": "Details anzeigen",
|
||||
"print_report_collection_details_ignored_notes": "Ignorierte Notizen"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "Titel der Notiz hier eingeben…"
|
||||
"placeholder": "Titel der Notiz hier eingeben…",
|
||||
"created_on": "Erstellt am <Value />",
|
||||
"last_modified": "Bearbeitet am <Value />",
|
||||
"note_type_switcher_label": "Ändere von {{type}} zu:",
|
||||
"note_type_switcher_others": "Andere Notizart",
|
||||
"note_type_switcher_templates": "Template",
|
||||
"note_type_switcher_collection": "Sammlung",
|
||||
"edited_notes": "Notizen, bearbeitet an diesem Tag",
|
||||
"promoted_attributes": "Hervorgehobene Attribute"
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "Es wurden keine Notizen mit den angegebenen Suchparametern gefunden.",
|
||||
@@ -1565,7 +1603,8 @@
|
||||
},
|
||||
"toc": {
|
||||
"table_of_contents": "Inhaltsverzeichnis",
|
||||
"options": "Optionen"
|
||||
"options": "Optionen",
|
||||
"no_headings": "Keine Überschriften."
|
||||
},
|
||||
"watched_file_update_status": {
|
||||
"file_last_modified": "Datei <code class=\"file-path\"></code> wurde zuletzt geändert am <span class=\"file-last-modified\"></span>.",
|
||||
@@ -2104,5 +2143,10 @@
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "Wechsele zum vollständigen Editor"
|
||||
},
|
||||
"experimental_features": {
|
||||
"title": "Experimentelle Optionen",
|
||||
"disclaimer": "Diese Optionen sind experimentell und können Instabilitäten verursachen. Achtsam zu verwenden.",
|
||||
"new_layout_name": "Neues Layout"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,6 @@
|
||||
"download_button": "Download",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "File size:",
|
||||
"preview": "Preview:",
|
||||
"preview_not_available": "Preview isn't available for this note type."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
@@ -765,9 +764,16 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Change note icon",
|
||||
"category": "Category:",
|
||||
"search": "Search:",
|
||||
"reset-default": "Reset to default icon"
|
||||
"search_placeholder_one": "Search {{number}} icons across {{count}} packs",
|
||||
"search_placeholder_other": "Search {{number}} icons across {{count}} packs",
|
||||
"search_placeholder_filtered": "Search {{number}} icons in {{name}}",
|
||||
"reset-default": "Reset to default icon",
|
||||
"filter": "Filter",
|
||||
"filter-none": "All icons",
|
||||
"filter-default": "Default icons",
|
||||
"icon_tooltip": "{{name}}\nIcon pack: {{iconPack}}",
|
||||
"no_results": "No icons found."
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Note type",
|
||||
@@ -1613,7 +1619,7 @@
|
||||
"will_be_deleted_in": "This attachment will be automatically deleted in {{time}}",
|
||||
"will_be_deleted_soon": "This attachment will be automatically deleted soon",
|
||||
"deletion_reason": ", because the attachment is not linked in the note's content. To prevent deletion, add the attachment link back into the content or convert the attachment into note.",
|
||||
"role_and_size": "Role: {{role}}, Size: {{size}}",
|
||||
"role_and_size": "Role: {{role}}, size: {{size}}, MIME: {{- mimeType}}",
|
||||
"link_copied": "Attachment link copied to clipboard.",
|
||||
"unrecognized_role": "Unrecognized attachment role '{{role}}'."
|
||||
},
|
||||
@@ -1762,7 +1768,11 @@
|
||||
"create-child-note": "Create child note",
|
||||
"unhoist": "Unhoist",
|
||||
"toggle-sidebar": "Toggle sidebar",
|
||||
"dropping-not-allowed": "Dropping notes into this location is not allowed."
|
||||
"dropping-not-allowed": "Dropping notes into this location is not allowed.",
|
||||
"clone-indicator-tooltip": "This note has {{- count}} parents: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "This note is cloned (1 additional parent: {{- parent}})",
|
||||
"shared-indicator-tooltip": "This note is shared publicly",
|
||||
"shared-indicator-tooltip-with-url": "This note is shared publicly at: {{- url}}"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Keep Window on Top"
|
||||
@@ -2198,7 +2208,14 @@
|
||||
"execute_script": "Run script",
|
||||
"execute_script_description": "This note is a script note. Click to execute the script.",
|
||||
"execute_sql": "Run SQL",
|
||||
"execute_sql_description": "This note is a SQL note. Click to execute the SQL query."
|
||||
"execute_sql_description": "This note is a SQL note. Click to execute the SQL query.",
|
||||
"save_status_saved": "Saved",
|
||||
"save_status_saving": "Saving...",
|
||||
"save_status_unsaved": "Unsaved",
|
||||
"save_status_error": "Save failed",
|
||||
"save_status_saving_tooltip": "Changes are being saved.",
|
||||
"save_status_unsaved_tooltip": "There are unsaved changes. They will be saved automatically in a moment.",
|
||||
"save_status_error_tooltip": "An error occurred while saving the note. If possible, try copying the note content elsewhere and reloading the application."
|
||||
},
|
||||
"status_bar": {
|
||||
"language_title": "Change content language",
|
||||
@@ -2227,5 +2244,15 @@
|
||||
"empty_button": "Hide the panel",
|
||||
"toggle": "Toggle right panel",
|
||||
"custom_widget_go_to_source": "Go to source code"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} attachment",
|
||||
"attachments_other": "{{count}} attachments",
|
||||
"layers_one": "{{count}} layer",
|
||||
"layers_other": "{{count}} layers",
|
||||
"pages_one": "{{count}} page",
|
||||
"pages_other": "{{count}} pages",
|
||||
"pages_alt": "Page {{pageNumber}}",
|
||||
"pages_loading": "Loading..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +279,6 @@
|
||||
"download_button": "Descargar",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Tamaño del archivo:",
|
||||
"preview": "Vista previa:",
|
||||
"preview_not_available": "La vista previa no está disponible para este tipo de notas.",
|
||||
"diff_off": "Mostrar contenido",
|
||||
"diff_on": "Mostrar diferencia",
|
||||
@@ -749,7 +748,6 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Cambiar icono de nota",
|
||||
"category": "Categoría:",
|
||||
"search": "Búsqueda:",
|
||||
"reset-default": "Restablecer a icono por defecto"
|
||||
},
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"bundle-error": {
|
||||
"title": "Echec du chargement d'un script personnalisé",
|
||||
"message": "Le script de la note avec l'ID \"{{id}}\", intitulé \"{{title}}\" n'a pas pu être exécuté à cause de\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Impossible d'obtenir la liste des widgets depuis le serveur"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Rendu impossible d'un widget React custom"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
@@ -279,7 +285,6 @@
|
||||
"download_button": "Télécharger",
|
||||
"mime": "MIME : ",
|
||||
"file_size": "Taille du fichier :",
|
||||
"preview": "Aperçu :",
|
||||
"preview_not_available": "L'aperçu n'est pas disponible pour ce type de note.",
|
||||
"restore_button": "Restaurer",
|
||||
"delete_button": "Supprimer",
|
||||
@@ -756,9 +761,12 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Changer l'icône de note",
|
||||
"category": "Catégorie :",
|
||||
"search": "Recherche :",
|
||||
"reset-default": "Réinitialiser l'icône par défaut"
|
||||
"reset-default": "Réinitialiser l'icône par défaut",
|
||||
"filter": "Filtre",
|
||||
"filter-none": "Toutes les icônes",
|
||||
"filter-default": "Icônes par défaut",
|
||||
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Type de note",
|
||||
@@ -1542,7 +1550,8 @@
|
||||
"refresh-saved-search-results": "Rafraîchir les résultats de recherche enregistrée",
|
||||
"create-child-note": "Créer une note enfant",
|
||||
"unhoist": "Désactiver le focus",
|
||||
"toggle-sidebar": "Basculer la barre latérale"
|
||||
"toggle-sidebar": "Basculer la barre latérale",
|
||||
"dropping-not-allowed": "Lâcher des notes à cet endroit n'est pas autorisé"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Épingler cette fenêtre au premier plan"
|
||||
@@ -1550,10 +1559,19 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Impossible de trouver typeWidget pour le type '{{type}}'",
|
||||
"printing": "Impression en cours...",
|
||||
"printing_pdf": "Export au format PDF en cours..."
|
||||
"printing_pdf": "Export au format PDF en cours...",
|
||||
"print_report_title": "Imprimer le rapport",
|
||||
"print_report_collection_details_button": "Consulter les détails",
|
||||
"print_report_collection_details_ignored_notes": "Notes ignorées"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "saisir le titre de la note ici..."
|
||||
"placeholder": "saisir le titre de la note ici...",
|
||||
"created_on": "Créé le <Value />",
|
||||
"last_modified": "Modifié le <Value />",
|
||||
"note_type_switcher_label": "Basculer de {{type}} à :",
|
||||
"note_type_switcher_others": "Autre type de note",
|
||||
"note_type_switcher_templates": "Modèle",
|
||||
"note_type_switcher_collection": "Collection"
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "Aucune note n'a été trouvée pour les paramètres de recherche donnés.",
|
||||
@@ -1582,7 +1600,8 @@
|
||||
},
|
||||
"toc": {
|
||||
"table_of_contents": "Table des matières",
|
||||
"options": "Options"
|
||||
"options": "Options",
|
||||
"no_headings": "Pas d'en-tête."
|
||||
},
|
||||
"watched_file_update_status": {
|
||||
"file_last_modified": "Le fichier <code class=\"file-path\"></code> a été modifié pour la dernière fois le <span class=\"file-last-modified\"></span>.",
|
||||
@@ -1683,7 +1702,8 @@
|
||||
"copy-link": "Copier le lien",
|
||||
"paste": "Coller",
|
||||
"paste-as-plain-text": "Coller comme texte brut",
|
||||
"search_online": "Rechercher «{{term}}» avec {{searchEngine}}"
|
||||
"search_online": "Rechercher «{{term}}» avec {{searchEngine}}",
|
||||
"search_in_trilium": "Rechercher \"{{term}}\" dans Trilium"
|
||||
},
|
||||
"image_context_menu": {
|
||||
"copy_reference_to_clipboard": "Copier la référence dans le presse-papiers",
|
||||
@@ -1992,7 +2012,8 @@
|
||||
"add-column": "Ajouter une colonne",
|
||||
"add-column-placeholder": "Entrez le nom de la colonne...",
|
||||
"edit-note-title": "Cliquez pour modifier le titre de la note",
|
||||
"edit-column-title": "Cliquez pour modifier le titre de la colonne"
|
||||
"edit-column-title": "Cliquez pour modifier le titre de la colonne",
|
||||
"column-already-exists": "Cette colonne existe déjà dans le tableau."
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Modifier cette diapositive",
|
||||
@@ -2076,7 +2097,8 @@
|
||||
"button_title": "Exporter le diagramme au format PNG"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "Le diagramme n'a pas pu être exporté au format PNG."
|
||||
"export_to_png": "Le diagramme n'a pas pu être exporté au format PNG.",
|
||||
"export_to_svg": "Le diagramme n'a pas pu être exporté en SVG."
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "Apparence",
|
||||
@@ -2109,6 +2131,10 @@
|
||||
},
|
||||
"read-only-info": {
|
||||
"read-only-note": "Vous consultez actuellement une note en lecture seule.",
|
||||
"auto-read-only-note": "Cette note s'affiche en mode lecture seule pour un chargement plus rapide."
|
||||
"auto-read-only-note": "Cette note s'affiche en mode lecture seule pour un chargement plus rapide.",
|
||||
"edit-note": "Editer la note"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "Effacer la note..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "ट्रिलियम नोट्स के बारें में"
|
||||
"title": "ट्रिलियम नोट्स के बारें में",
|
||||
"build_date": "निर्माण की तारीख:"
|
||||
},
|
||||
"toast": {
|
||||
"widget-error": {
|
||||
"title": "एक विजेट को इनिशियलाइज़ करने में विफल रहा"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "एक कस्टम स्क्रिप्ट लोड करने में विफल रहा"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "सर्वर से विजेट्स की सूची प्राप्त करने में विफल"
|
||||
},
|
||||
"open-script-note": "स्क्रिप्ट नोट खोलें"
|
||||
},
|
||||
"update_available": {
|
||||
"update_available": "उपलब्ध अद्यतन"
|
||||
},
|
||||
"code_buttons": {
|
||||
"execute_button_title": "स्क्रिप्ट एक्सीक्यूट करें",
|
||||
"trilium_api_docs_button_title": "ट्रिलियम एपीआई डॉक्स खोलें",
|
||||
"save_to_note_button_title": "नोट में सेव करें"
|
||||
},
|
||||
"hide_floating_buttons_button": {
|
||||
"button_title": "बटन छुपाएं"
|
||||
},
|
||||
"show_floating_buttons_button": {
|
||||
"button_title": "बटन दिखाएं"
|
||||
},
|
||||
"add_link": {
|
||||
"note": "नोट"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "Nem sikerült betölteni az egyéni szkriptet",
|
||||
"message": "A(z) \"{{id}}\" azonosítójú, \"{{title}}\" című jegyzetből származó szkript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||
"message": "A skript nem hajtható végre a következő ok miatt:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "A Widget-ek letöltése sikertelen volt"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Nem sikerült renderelni a React widget-et"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
|
||||
@@ -16,13 +16,22 @@
|
||||
},
|
||||
"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}}"
|
||||
"message": "Impossibile eseguire lo script a causa di:\n\n{{message}}"
|
||||
},
|
||||
"widget-error": {
|
||||
"title": "Impossibile inizializzare un widget",
|
||||
"message-custom": "Il widget personalizzato dalla nota con ID “{{id}}”, intitolato “{{title}}”, non è stato possibile inizializzare a causa di:\n\n{{message}}",
|
||||
"message-unknown": "Un widget sconosciuto non è stato inizializzato a causa di:\n\n{{message}}"
|
||||
}
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Impossibile ottenere l'elenco dei widget dal server"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Impossibile eseguire il rendering di un widget React personalizzato"
|
||||
},
|
||||
"widget-missing-parent": "Il widget personalizzato non ha la proprietà obbligatoria '{{property}}' definita.\n\nSe questo script deve essere eseguito senza un elemento dell'interfaccia utente, utilizzare invece '#run=frontendStartup'.",
|
||||
"open-script-note": "Apri script note",
|
||||
"scripting-error": "Errore script personalizzato: {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "Aggiungi un collegamento",
|
||||
@@ -893,7 +902,6 @@
|
||||
"download_button": "Scarica",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Dimensione del file:",
|
||||
"preview": "Anteprima:",
|
||||
"preview_not_available": "L'anteprima non è disponibile per questo tipo di nota."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
@@ -1333,9 +1341,17 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Cambia icona nota",
|
||||
"category": "Categoria:",
|
||||
"search": "Ricerca:",
|
||||
"reset-default": "Ripristina l'icona predefinita"
|
||||
"reset-default": "Ripristina l'icona predefinita",
|
||||
"search_placeholder_one": "Cerca {{number}} icona in {{count}} pacchetto",
|
||||
"search_placeholder_many": "Cerca {{number}} icone in {{count}} pacchetti",
|
||||
"search_placeholder_other": "Cerca {{number}} icone in {{count}} pacchetti",
|
||||
"search_placeholder_filtered": "Cerca {{number}} icone in {{name}}",
|
||||
"filter": "Filtro",
|
||||
"filter-none": "Tutte le icone",
|
||||
"filter-default": "Icone predefinite",
|
||||
"icon_tooltip": "{{name}}\nPacchetto icone: {{iconPack}}",
|
||||
"no_results": "Nessuna icona trovata."
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Tipo di nota",
|
||||
@@ -1793,7 +1809,7 @@
|
||||
"will_be_deleted_in": "Questo allegato verrà eliminato automaticamente tra {{time}}",
|
||||
"will_be_deleted_soon": "Questo allegato verrà eliminato automaticamente a breve",
|
||||
"deletion_reason": ", perché l'allegato non è collegato al contenuto della nota. Per impedirne l'eliminazione, aggiungi nuovamente il collegamento all'allegato nel contenuto o converti l'allegato in nota.",
|
||||
"role_and_size": "Ruolo: {{role}}, Dimensione: {{size}}",
|
||||
"role_and_size": "Ruolo: {{role}}, dimensione: {{size}}, MIME: {{- mimeType}}",
|
||||
"link_copied": "Link all'allegato copiato negli appunti.",
|
||||
"unrecognized_role": "Ruolo di allegato non riconosciuto '{{role}}'."
|
||||
},
|
||||
@@ -1887,7 +1903,13 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Impossibile trovare typeWidget per il tipo '{{type}}'",
|
||||
"printing": "Stampa in corso...",
|
||||
"printing_pdf": "Esportazione in PDF in corso..."
|
||||
"printing_pdf": "Esportazione in PDF in corso...",
|
||||
"print_report_title": "Stampa rapporto",
|
||||
"print_report_collection_content_one": "{{count}} la note nella raccolta non può essere stampata perché non è supportata o è protetta.",
|
||||
"print_report_collection_content_many": "{{count}} le note nella raccolta non possono essere stampate perché non sono supportate o sono protette.",
|
||||
"print_report_collection_content_other": "{{count}} le note nella raccolta non possono essere stampate perché non sono supportate o sono protette.",
|
||||
"print_report_collection_details_button": "Vedi dettagli",
|
||||
"print_report_collection_details_ignored_notes": "Note ignorate"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "scrivi qui il titolo della nota...",
|
||||
@@ -1897,7 +1919,8 @@
|
||||
"note_type_switcher_others": "Altro tipo di nota",
|
||||
"note_type_switcher_templates": "Modello",
|
||||
"note_type_switcher_collection": "Collezione",
|
||||
"edited_notes": "Note modificate"
|
||||
"edited_notes": "Note modificate in questo giorno",
|
||||
"promoted_attributes": "Attributi promossi"
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "Non sono state trovate note per i parametri di ricerca specificati.",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"widget-render-error": {
|
||||
"title": "カスタム React ウィジェットのレンダリングに失敗しました"
|
||||
},
|
||||
"widget-missing-parent": "カスタムウィジェットに必須の '{{property}}' プロパティが定義されていません。",
|
||||
"widget-missing-parent": "カスタムウィジェットに必須の '{{property}}' プロパティが定義されていません。\n\nこのスクリプトを UI 要素なしで実行する場合は、代わりに '#run=frontendStartup' を使用してください。",
|
||||
"open-script-note": "スクリプトノートを開く",
|
||||
"scripting-error": "カスタムスクリプトエラー: {{title}}"
|
||||
},
|
||||
@@ -152,16 +152,22 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "ノートアイコンの変更",
|
||||
"category": "カテゴリー:",
|
||||
"search": "検索:",
|
||||
"reset-default": "アイコンをデフォルトに戻す"
|
||||
"reset-default": "アイコンをデフォルトに戻す",
|
||||
"search_placeholder_other": "{{count}} 個のパックから {{number}} 個のアイコンを検索",
|
||||
"search_placeholder_filtered": "{{name}} で {{number}} 個のアイコンを検索",
|
||||
"filter": "フィルター",
|
||||
"filter-none": "すべてのアイコン",
|
||||
"filter-default": "デフォルトアイコン",
|
||||
"icon_tooltip": "{{name}}\nアイコンパック: {{iconPack}}",
|
||||
"no_results": "アイコンが見つかりません。"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "ノートタイプ",
|
||||
"editable": "編集可能",
|
||||
"basic_properties": "基本プロパティ",
|
||||
"language": "言語",
|
||||
"configure_code_notes": "コードノートを設定しています..."
|
||||
"configure_code_notes": "コードノートを設定..."
|
||||
},
|
||||
"i18n": {
|
||||
"title": "ローカライゼーション",
|
||||
@@ -648,7 +654,6 @@
|
||||
"revision_deleted": "ノートの変更履歴は削除されました。",
|
||||
"settings": "ノートの変更履歴の設定",
|
||||
"file_size": "ファイルサイズ:",
|
||||
"preview": "プレビュー:",
|
||||
"preview_not_available": "このノートタイプではプレビューは利用できません。",
|
||||
"diff_on": "差分を表示",
|
||||
"diff_off": "内容を表示",
|
||||
@@ -1239,7 +1244,11 @@
|
||||
"saved-search-note-refreshed": "保存した検索ノートが更新されました。",
|
||||
"refresh-saved-search-results": "保存した検索結果を更新",
|
||||
"toggle-sidebar": "サイドバーを切り替え",
|
||||
"dropping-not-allowed": "この場所にノートをドロップすることはできません。"
|
||||
"dropping-not-allowed": "この場所にノートをドロップすることはできません。",
|
||||
"clone-indicator-tooltip": "このノートには {{- count}} 個の親があります: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "このノートは複製されています (親が 1 件追加: {{- parent}})",
|
||||
"shared-indicator-tooltip": "このノートは公開されています",
|
||||
"shared-indicator-tooltip-with-url": "このノートは以下で公開されています: {{- url}}"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "一括操作",
|
||||
@@ -1930,7 +1939,11 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "タイプ {{type}} の typeWidget が見つかりませんでした",
|
||||
"printing": "印刷中です...",
|
||||
"printing_pdf": "PDF へのエクスポート中です..."
|
||||
"printing_pdf": "PDF へのエクスポート中です...",
|
||||
"print_report_title": "レポートを印刷",
|
||||
"print_report_collection_content_other": "コレクション内の {{count}} 件のノートは、サポートされていないか保護されているため、印刷できませんでした。",
|
||||
"print_report_collection_details_button": "詳細を見る",
|
||||
"print_report_collection_details_ignored_notes": "無視されたノート"
|
||||
},
|
||||
"watched_file_update_status": {
|
||||
"ignore_this_change": "この変更を無視する",
|
||||
@@ -2126,7 +2139,7 @@
|
||||
"will_be_deleted_in": "この添付ファイルは {{time}} 後に自動的に削除されます",
|
||||
"will_be_deleted_soon": "この添付ファイルはすぐに自動的に削除されます",
|
||||
"deletion_reason": "、添付ファイルがノートのコンテンツにリンクされていないためです。削除されないようにするには、添付ファイルのリンクをコンテンツに再度追加するか、添付ファイルをノートに変換してください。",
|
||||
"role_and_size": "ロール: {{role}},サイズ: {{size}}",
|
||||
"role_and_size": "ロール: {{role}},サイズ: {{size}}, MIME: {{- mimeType}}",
|
||||
"link_copied": "添付ファイルのリンクをクリップボードにコピーしました。",
|
||||
"unrecognized_role": "添付ファイルのロール「{{role}}」は認識されません。"
|
||||
},
|
||||
@@ -2183,7 +2196,14 @@
|
||||
"execute_sql_description": "このノートは SQL ノートです。クリックすると SQL クエリが実行されます。",
|
||||
"shared_copy_to_clipboard": "リンクをクリップボードにコピー",
|
||||
"shared_open_in_browser": "ブラウザでリンクを開く",
|
||||
"shared_unshare": "共有を削除"
|
||||
"shared_unshare": "共有を削除",
|
||||
"save_status_saved": "保存されました",
|
||||
"save_status_saving": "保存中...",
|
||||
"save_status_unsaved": "未保存",
|
||||
"save_status_error": "保存に失敗しました",
|
||||
"save_status_saving_tooltip": "変更を保存しています。",
|
||||
"save_status_unsaved_tooltip": "未保存の変更があります。すぐに自動的に保存されます。",
|
||||
"save_status_error_tooltip": "ノートの保存中にエラーが発生しました。可能であれば、ノートの内容を別の場所にコピーして、アプリケーションを再読み込みしてください。"
|
||||
},
|
||||
"status_bar": {
|
||||
"language_title": "コンテンツの言語を変更",
|
||||
@@ -2214,5 +2234,12 @@
|
||||
},
|
||||
"attributes_panel": {
|
||||
"title": "ノート属性"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_other": "{{count}} 添付ファイル",
|
||||
"layers_other": "{{count}} 層",
|
||||
"pages_other": "{{count}} ページ",
|
||||
"pages_alt": "ページ {{pageNumber}}",
|
||||
"pages_loading": "読み込み中..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
{}
|
||||
{
|
||||
"about": {
|
||||
"title": "Om Trilium Notes",
|
||||
"app_version": "App versjon:",
|
||||
"db_version": "DB versjon:",
|
||||
"sync_version": "Synk versjon:",
|
||||
"build_date": "Byggdato:",
|
||||
"build_revision": "Bygg versjon:",
|
||||
"data_directory": "Datamappe:",
|
||||
"homepage": "Hjemmeside:"
|
||||
},
|
||||
"experimental_features": {
|
||||
"new_layout_description": "Prøv det nye grensesnittet for et mer moderne utseende og forbedret brukervenlighet. Det må påregnes betydelige endringer i kommende versjoner."
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"recommendation": "For den beste brukeropplevelsen, vennligst last ned den tilpassede ARM64-versjonen av TriliumNext fra siden for utgivelser."
|
||||
},
|
||||
"zpetne_odkazy": {
|
||||
"backlink_one": "{{count}} Tilbakelenke",
|
||||
"backlink_other": "{{count}} Tilbakelenker"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,7 +959,6 @@
|
||||
"download_button": "Pobierz",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Rozmiar pliku:",
|
||||
"preview": "Podgląd:",
|
||||
"preview_not_available": "Podgląd nie jest dostępny dla tego typu notatki."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
@@ -1286,7 +1285,6 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Zmień ikonę notatki",
|
||||
"category": "Kategoria:",
|
||||
"search": "Szukaj:",
|
||||
"reset-default": "Przywróć domyślną ikonę"
|
||||
},
|
||||
|
||||
@@ -274,7 +274,6 @@
|
||||
"download_button": "Descarregar",
|
||||
"mime": "MIME: ",
|
||||
"file_size": "Tamanho do ficheiro:",
|
||||
"preview": "Visualizar:",
|
||||
"preview_not_available": "A visualização não está disponível para este tipo de nota."
|
||||
},
|
||||
"sort_child_notes": {
|
||||
@@ -724,7 +723,6 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Alterar ícone da nota",
|
||||
"category": "Categoria:",
|
||||
"search": "Pesquisa:",
|
||||
"reset-default": "Redefinir para o ícone padrão"
|
||||
},
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"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.",
|
||||
"diff_on": "Exibir diferença",
|
||||
"diff_off": "Exibir conteúdo",
|
||||
@@ -1008,7 +1007,6 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Alterar ícone da nota",
|
||||
"category": "Categoria:",
|
||||
"search": "Busca:",
|
||||
"reset-default": "Redefinir para o ícone padrão"
|
||||
},
|
||||
|
||||
@@ -1103,7 +1103,6 @@
|
||||
"mime": "MIME: ",
|
||||
"no_revisions": "Nu există încă nicio revizie pentru această notiță...",
|
||||
"note_revisions": "Revizii ale notiței",
|
||||
"preview": "Previzualizare:",
|
||||
"preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.",
|
||||
"restore_button": "Restaurează",
|
||||
"revision_deleted": "Revizia notiței a fost ștearsă.",
|
||||
@@ -1483,7 +1482,6 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Schimbă iconița notiței",
|
||||
"category": "Categorie:",
|
||||
"reset-default": "Resetează la iconița implicită",
|
||||
"search": "Căutare:"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"widget-render-error": {
|
||||
"title": "Не удалось отобразить пользовательский React виджет"
|
||||
},
|
||||
"widget-missing-parent": "В пользовательском виджете не определено обязательное свойство '{{property}}'.",
|
||||
"widget-missing-parent": "В пользовательском виджете не определено обязательное свойство '{{property}}'.\n\nЕсли этот скрипт предназначен для запуска без элемента пользовательского интерфейса, используйте '#run=frontendStartup'.",
|
||||
"open-script-note": "Открыть заметку со скриптом",
|
||||
"scripting-error": "Ошибка пользовательского скрипта: {{title}}"
|
||||
},
|
||||
@@ -387,7 +387,6 @@
|
||||
"revision_deleted": "Версия заметки была удалена.",
|
||||
"download_button": "Скачать",
|
||||
"file_size": "Размер файла:",
|
||||
"preview": "Предпросмотр:",
|
||||
"preview_not_available": "Предпосмотр недоступен для заметки этого типа.",
|
||||
"mime": "MIME: ",
|
||||
"settings": "Настройка версионирования заметок",
|
||||
@@ -1010,10 +1009,18 @@
|
||||
"backlink_many": "{{count}} обратных ссылок"
|
||||
},
|
||||
"note_icon": {
|
||||
"category": "Категория:",
|
||||
"search": "Поиск:",
|
||||
"change_note_icon": "Изменить иконку заметки",
|
||||
"reset-default": "Сбросить к значку по умолчанию"
|
||||
"reset-default": "Сбросить к значку по умолчанию",
|
||||
"no_results": "Иконки не найдены.",
|
||||
"icon_tooltip": "{{name}}\nНабор иконок: {{iconPack}}",
|
||||
"filter-default": "Иконки по-умолчанию",
|
||||
"filter-none": "Все иконки",
|
||||
"filter": "Фильтр",
|
||||
"search_placeholder_filtered": "Поиск {{number}} иконок в {{name}}",
|
||||
"search_placeholder_one": "Поиск {{number}} иконки среди {{count}} наборов",
|
||||
"search_placeholder_few": "Поиск {{number}} иконок среди {{count}} наборов",
|
||||
"search_placeholder_many": "Поиск {{number}} иконок среди {{count}} наборов"
|
||||
},
|
||||
"basic_properties": {
|
||||
"editable": "Изменяемое",
|
||||
@@ -2026,7 +2033,7 @@
|
||||
"lost-websocket-connection-message": "Проверьте конфигурацию обратного прокси (например, nginx или Apache), чтобы убедиться, что соединения WebSocket должным образом разрешены и не заблокированы."
|
||||
},
|
||||
"attachment_detail_2": {
|
||||
"role_and_size": "Роль: {{role}}, Размер: {{size}}",
|
||||
"role_and_size": "Роль: {{role}}, размер: {{size}}, MIME: {{- mimeType}}",
|
||||
"unrecognized_role": "Нераспознанная роль вложения '{{role}}'.",
|
||||
"link_copied": "Ссылка на вложение скопирована в буфер обмена.",
|
||||
"will_be_deleted_soon": "Это вложение скоро будет автоматически удалено",
|
||||
@@ -2112,7 +2119,13 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Не удалось найти typeWidget для типа '{{type}}'",
|
||||
"printing_pdf": "Выполняется экспорт PDF...",
|
||||
"printing": "Выполняется печать..."
|
||||
"printing": "Выполняется печать...",
|
||||
"print_report_title": "Отчет по печати",
|
||||
"print_report_collection_content_one": "{{count}} заметка в коллекции не удалось распечатать, поскольку она не поддерживается или защищена.",
|
||||
"print_report_collection_content_few": "{{count}} заметки в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_content_many": "{{count}} заметок в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_details_button": "Подробнее",
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего. Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>.",
|
||||
|
||||
@@ -271,7 +271,6 @@
|
||||
"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": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "載入自訂腳本失敗",
|
||||
"message": "來自 ID 為 \"{{id}}\"、標題為 \"{{title}}\" 的筆記的腳本因以下原因無法執行:\n\n{{message}}"
|
||||
"message": "腳本因以下原因無法執行:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "無法從伺服器取得元件清單"
|
||||
@@ -29,8 +29,9 @@
|
||||
"widget-render-error": {
|
||||
"title": "無法渲染自訂 React 元件"
|
||||
},
|
||||
"widget-missing-parent": "自訂元件未定義強制性的 \"{{property}}\" 屬性。",
|
||||
"open-script-note": "打開腳本筆記"
|
||||
"widget-missing-parent": "自訂元件未定義強制性的 \"{{property}}\" 屬性。\n\n若此腳本需在無 UI 的情況下執行,請改用 \"#run=frontendStartup\"。",
|
||||
"open-script-note": "打開腳本筆記",
|
||||
"scripting-error": "自訂腳本錯誤:{{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "新增連結",
|
||||
@@ -287,7 +288,6 @@
|
||||
"download_button": "下載",
|
||||
"mime": "MIME類型: ",
|
||||
"file_size": "檔案大小:",
|
||||
"preview": "預覽:",
|
||||
"preview_not_available": "無法預覽此類型的筆記。",
|
||||
"restore_button": "還原",
|
||||
"delete_button": "刪除",
|
||||
@@ -761,9 +761,16 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "更改筆記圖標",
|
||||
"category": "類別:",
|
||||
"search": "搜尋:",
|
||||
"reset-default": "重置為預設圖標"
|
||||
"reset-default": "重置為預設圖標",
|
||||
"search_placeholder_one": "在 {{count}} 個圖示包中搜尋 {{number}} 個圖示",
|
||||
"search_placeholder_other": "",
|
||||
"search_placeholder_filtered": "在 {{name}} 中搜尋 {{number}} 個圖示",
|
||||
"filter": "篩選",
|
||||
"filter-none": "所有圖示",
|
||||
"filter-default": "預設圖示",
|
||||
"icon_tooltip": "{{name}}\n圖示包:{{iconPack}}",
|
||||
"no_results": "找不到圖示。"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "筆記類型",
|
||||
@@ -1405,7 +1412,7 @@
|
||||
"will_be_deleted_in": "此附件將在 {{time}} 後自動刪除",
|
||||
"will_be_deleted_soon": "該附件即將被自動刪除",
|
||||
"deletion_reason": ",因為該附件未連結在筆記的內容中。為防止被刪除,請將附件連結重新新增至內容中或將附件轉換為筆記。",
|
||||
"role_and_size": "角色:{{role}},大小:{{size}}",
|
||||
"role_and_size": "角色:{{role}},大小:{{size}},MIME:{{- mimeType}}",
|
||||
"link_copied": "已複製附件連結到剪貼簿。",
|
||||
"unrecognized_role": "無法識別的附件角色 '{{role}}'。"
|
||||
},
|
||||
@@ -1549,7 +1556,11 @@
|
||||
"create-child-note": "建立子筆記",
|
||||
"unhoist": "取消聚焦",
|
||||
"toggle-sidebar": "切換側邊欄",
|
||||
"dropping-not-allowed": "不允許移動筆記至此處。"
|
||||
"dropping-not-allowed": "不允許移動筆記至此處。",
|
||||
"clone-indicator-tooltip": "此筆記有 {{- count}} 個父級:{{- parents}}",
|
||||
"clone-indicator-tooltip-single": "此筆記已克隆(新增 1 個父級:{{- parent}})",
|
||||
"shared-indicator-tooltip": "此筆記已公開分享",
|
||||
"shared-indicator-tooltip-with-url": "此筆記已公開分享至:{{- url}}"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "保持此視窗置頂"
|
||||
@@ -1557,7 +1568,12 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "找不到類型為 '{{type}}' 的 typeWidget",
|
||||
"printing": "正在列印…",
|
||||
"printing_pdf": "正在匯出為 PDF…"
|
||||
"printing_pdf": "正在匯出為 PDF…",
|
||||
"print_report_title": "列印報告",
|
||||
"print_report_collection_content_one": "集合中的 {{count}} 篇筆記無法列印,因為它們不被支援或受到保護。",
|
||||
"print_report_collection_content_other": "",
|
||||
"print_report_collection_details_button": "查看詳情",
|
||||
"print_report_collection_details_ignored_notes": "忽略的筆記"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "請輸入筆記標題...",
|
||||
@@ -1567,7 +1583,8 @@
|
||||
"note_type_switcher_others": "其他筆記類型",
|
||||
"note_type_switcher_templates": "模板",
|
||||
"note_type_switcher_collection": "集合",
|
||||
"edited_notes": "編輯過的筆記"
|
||||
"edited_notes": "今天編輯過的筆記",
|
||||
"promoted_attributes": "升級屬性"
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "沒有找到符合搜尋條件的筆記。",
|
||||
@@ -2183,7 +2200,14 @@
|
||||
"read_only_temporarily_disabled_description": "此筆記目前可編輯,但通常為唯讀狀態。當您切換至其他筆記時,本筆記將立即恢復為唯讀模式。\n\n點擊此處重新啟用唯讀模式。",
|
||||
"clipped_note_description": "本筆記原始來源為 {{url}}。\n\n點擊此處前往原網頁。",
|
||||
"execute_script_description": "此筆記為腳本筆記。點擊以執行腳本。",
|
||||
"execute_sql_description": "此筆記為 SQL 筆記。點擊以執行 SQL 查詢。"
|
||||
"execute_sql_description": "此筆記為 SQL 筆記。點擊以執行 SQL 查詢。",
|
||||
"save_status_saved": "已儲存",
|
||||
"save_status_saving": "正在儲存…",
|
||||
"save_status_unsaved": "未儲存",
|
||||
"save_status_error": "儲存失敗",
|
||||
"save_status_saving_tooltip": "正在儲存更動。",
|
||||
"save_status_unsaved_tooltip": "仍有更動尚未儲存。它們將在稍後自動儲存。",
|
||||
"save_status_error_tooltip": "在儲存筆記時發生錯誤。如果可以,請嘗試將筆記內容複製至他處並重新載入應用程式。"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"hoisted_badge": "聚焦",
|
||||
@@ -2220,5 +2244,15 @@
|
||||
},
|
||||
"attributes_panel": {
|
||||
"title": "筆記屬性"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_one": "{{count}} 個附件",
|
||||
"attachments_other": "",
|
||||
"layers_one": "{{count}} 層",
|
||||
"layers_other": "",
|
||||
"pages_one": "共 {{count}} 頁",
|
||||
"pages_other": "",
|
||||
"pages_alt": "第 {{pageNumber}} 頁",
|
||||
"pages_loading": "正在載入…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,6 @@
|
||||
"download_button": "Завантажити",
|
||||
"mime": "МІМЕ: ",
|
||||
"file_size": "Розмір файлу:",
|
||||
"preview": "Попередній перегляд:",
|
||||
"preview_not_available": "Попередній перегляд недоступний для цього типу нотатки.",
|
||||
"diff_on": "Показати різницю",
|
||||
"diff_off": "Показати вміст",
|
||||
@@ -849,7 +848,6 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Змінити значок нотатки",
|
||||
"category": "Категорія:",
|
||||
"search": "Пошук:",
|
||||
"reset-default": "Скинути значок до стандартного значення"
|
||||
},
|
||||
|
||||
2
apps/client/src/types-assets.d.ts
vendored
2
apps/client/src/types-assets.d.ts
vendored
@@ -17,5 +17,3 @@ declare module "*?raw" {
|
||||
var content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "boxicons/css/boxicons.min.css" { }
|
||||
|
||||
121
apps/client/src/types-pdfjs.d.ts
vendored
Normal file
121
apps/client/src/types-pdfjs.d.ts
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
type HistoryData = {
|
||||
files: {
|
||||
fingerprint: string;
|
||||
page: number;
|
||||
zoom: string;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
rotation: number;
|
||||
sidebarView: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
interface Window {
|
||||
/**
|
||||
* By default, pdf.js will try to store information about the opened PDFs such as zoom and scroll position in local storage.
|
||||
* The Trilium alternative is to use attachments stored at note level.
|
||||
* This variable represents the direct content used by the pdf.js viewer in its local storage key, but in plain JS object format.
|
||||
* The variable must be set early at startup, before pdf.js fully initializes.
|
||||
*/
|
||||
TRILIUM_VIEW_HISTORY_STORE?: HistoryData;
|
||||
|
||||
/**
|
||||
* If set to true, hides the pdf.js viewer default sidebar containing the outline, page navigation, etc.
|
||||
* This needs to be set early in the main method.
|
||||
*/
|
||||
TRILIUM_HIDE_SIDEBAR?: boolean;
|
||||
|
||||
TRILIUM_NOTE_ID: string;
|
||||
|
||||
TRILIUM_NTX_ID: string | null | undefined;
|
||||
}
|
||||
|
||||
interface PdfOutlineItem {
|
||||
title: string;
|
||||
level: number;
|
||||
dest: unknown;
|
||||
id: string;
|
||||
items: PdfOutlineItem[];
|
||||
}
|
||||
|
||||
interface WithContext {
|
||||
ntxId: string;
|
||||
noteId: string | null | undefined;
|
||||
}
|
||||
|
||||
interface PdfDocumentModifiedMessage extends WithContext {
|
||||
type: "pdfjs-viewer-document-modified";
|
||||
}
|
||||
|
||||
interface PdfDocumentBlobResultMessage extends WithContext {
|
||||
type: "pdfjs-viewer-blob";
|
||||
data: Uint8Array<ArrayBufferLike>;
|
||||
}
|
||||
|
||||
interface PdfSaveViewHistoryMessage extends WithContext {
|
||||
type: "pdfjs-viewer-save-view-history";
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface PdfViewerTocMessage {
|
||||
type: "pdfjs-viewer-toc";
|
||||
data: PdfOutlineItem[];
|
||||
}
|
||||
|
||||
interface PdfViewerActiveHeadingMessage {
|
||||
type: "pdfjs-viewer-active-heading";
|
||||
headingId: string;
|
||||
}
|
||||
|
||||
interface PdfViewerPageInfoMessage {
|
||||
type: "pdfjs-viewer-page-info";
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
interface PdfViewerCurrentPageMessage {
|
||||
type: "pdfjs-viewer-current-page";
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
interface PdfViewerThumbnailMessage {
|
||||
type: "pdfjs-viewer-thumbnail";
|
||||
pageNumber: number;
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
interface PdfAttachment {
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface PdfViewerAttachmentsMessage {
|
||||
type: "pdfjs-viewer-attachments";
|
||||
attachments: PdfAttachment[];
|
||||
downloadAttachment?: (fileName: string) => void;
|
||||
}
|
||||
|
||||
interface PdfLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface PdfViewerLayersMessage {
|
||||
type: "pdfjs-viewer-layers";
|
||||
layers: PdfLayer[];
|
||||
toggleLayer?: (layerId: string, visible: boolean) => void;
|
||||
}
|
||||
|
||||
type PdfMessageEvent = MessageEvent<
|
||||
PdfDocumentModifiedMessage
|
||||
| PdfSaveViewHistoryMessage
|
||||
| PdfViewerTocMessage
|
||||
| PdfViewerActiveHeadingMessage
|
||||
| PdfViewerPageInfoMessage
|
||||
| PdfViewerCurrentPageMessage
|
||||
| PdfViewerThumbnailMessage
|
||||
| PdfViewerAttachmentsMessage
|
||||
| PdfViewerLayersMessage
|
||||
| PdfDocumentBlobResultMessage
|
||||
>;
|
||||
4
apps/client/src/types.d.ts
vendored
4
apps/client/src/types.d.ts
vendored
@@ -1,3 +1,5 @@
|
||||
import { IconRegistry } from "@triliumnext/commons";
|
||||
|
||||
import appContext, { AppContext } from "./components/app_context";
|
||||
import type FNote from "./entities/fnote";
|
||||
import type { PrintReport } from "./print";
|
||||
@@ -34,6 +36,7 @@ interface CustomGlobals {
|
||||
isProtectedSessionAvailable: boolean;
|
||||
isDev: boolean;
|
||||
isMainWindow: boolean;
|
||||
isStandalone?: boolean;
|
||||
maxEntityChangeIdAtLoad: number;
|
||||
maxEntityChangeSyncIdAtLoad: number;
|
||||
assetPath: string;
|
||||
@@ -46,6 +49,7 @@ interface CustomGlobals {
|
||||
linter: typeof lint;
|
||||
hasNativeTitleBar: boolean;
|
||||
isRtl: boolean;
|
||||
iconRegistry: IconRegistry;
|
||||
}
|
||||
|
||||
type RequireMethod = (moduleName: string) => any;
|
||||
|
||||
@@ -142,7 +142,7 @@ function ShowTocWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingB
|
||||
|
||||
return isEnabled && <FloatingButton
|
||||
text={t("show_toc_widget_button.show_toc")}
|
||||
icon="bx bx-tn-toc"
|
||||
icon="bx bx-spreadsheet bx-rotate-180"
|
||||
onClick={() => {
|
||||
if (noteContext?.viewScope && noteContext.noteId) {
|
||||
noteContext.viewScope.tocTemporarilyHidden = false;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import FNote from "../../entities/fnote";
|
||||
import "./UserAttributesList.css";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import attributes from "../../services/attributes";
|
||||
import { DefinitionObject } from "../../services/promoted_attribute_definition_parser";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
|
||||
import type { DefinitionObject } from "@triliumnext/commons";
|
||||
import { ComponentChildren, CSSProperties } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import NoteLink from "../react/NoteLink";
|
||||
import { getReadableTextColor } from "../../services/css_class_manager";
|
||||
|
||||
interface UserAttributesListProps {
|
||||
note: FNote;
|
||||
@@ -29,7 +31,7 @@ export default function UserAttributesDisplay({ note, ignoredAttributes }: UserA
|
||||
<div className="user-attributes">
|
||||
{userAttributes?.map(attr => buildUserAttribute(attr))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -46,13 +48,13 @@ function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: stri
|
||||
}
|
||||
|
||||
function UserAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) {
|
||||
const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`;
|
||||
const className = `${attr.type === "label" ? `label` + ` ${ attr.def.labelType}` : "relation"}`;
|
||||
|
||||
return (
|
||||
<span key={attr.friendlyName} className={`user-attribute type-${className}`} style={style}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
@@ -61,7 +63,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
let style: CSSProperties | undefined;
|
||||
|
||||
if (attr.type === "label") {
|
||||
let value = attr.value;
|
||||
const value = attr.value;
|
||||
switch (attr.def.labelType) {
|
||||
case "number":
|
||||
let formattedValue = value;
|
||||
@@ -102,7 +104,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
|
||||
content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>;
|
||||
}
|
||||
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>
|
||||
return <UserAttribute attr={attr} style={style}>{content}</UserAttribute>;
|
||||
}
|
||||
|
||||
function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CommandNames } from "../../components/app_context";
|
||||
import Component from "../../components/component";
|
||||
import { ExperimentalFeature, ExperimentalFeatureId, experimentalFeatures, isExperimentalFeatureEnabled, toggleExperimentalFeature } from "../../services/experimental_features";
|
||||
import { t } from "../../services/i18n";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, reloadFrontendApp } from "../../services/utils";
|
||||
import utils, { dynamicRequire, isElectron, isMobile, isStandalone, reloadFrontendApp } from "../../services/utils";
|
||||
import Dropdown from "../react/Dropdown";
|
||||
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem } from "../react/FormList";
|
||||
import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool, useTriliumOptionInt } from "../react/hooks";
|
||||
@@ -249,7 +249,7 @@ function ToggleWindowOnTop() {
|
||||
function useTriliumUpdateStatus() {
|
||||
const [ latestVersion, setLatestVersion ] = useState<string>();
|
||||
const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates");
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion);
|
||||
const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, window.glob.triliumVersion);
|
||||
|
||||
async function updateVersionStatus() {
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest";
|
||||
@@ -267,7 +267,7 @@ function useTriliumUpdateStatus() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkForUpdates) {
|
||||
if (!checkForUpdates || !isStandalone) {
|
||||
setLatestVersion(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import options from "../../services/options";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { useTriliumOptionBool } from "../react/hooks";
|
||||
import { useState, useCallback } from "preact/hooks";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
|
||||
export default function RightPaneToggle() {
|
||||
const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible");
|
||||
const [ rightPaneVisible, setRightPaneVisible ] = useState(options.is("rightPaneVisible"));
|
||||
|
||||
useTriliumEvent("toggleRightPane", useCallback(() => {
|
||||
setRightPaneVisible(current => !current);
|
||||
}, []));
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
@@ -15,7 +21,7 @@ export default function RightPaneToggle() {
|
||||
)}
|
||||
text={t("right_pane.toggle")}
|
||||
icon="bx bx-sidebar"
|
||||
onClick={() => setRightPaneVisible(!rightPaneVisible)}
|
||||
triggerCommand="toggleRightPane"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ body.prefers-centered-content .note-list-widget:not(.full-height) {
|
||||
}
|
||||
|
||||
.note-list-widget .note-list {
|
||||
padding: 10px;
|
||||
padding-block: 10px;
|
||||
}
|
||||
|
||||
.note-list-widget.full-height,
|
||||
|
||||
@@ -11,7 +11,8 @@ import froca from "../../services/froca";
|
||||
import { subscribeToMessages, unsubscribeToMessage as unsubscribeFromMessage } from "../../services/ws";
|
||||
import { useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent } from "../react/hooks";
|
||||
import { allViewTypes, ViewModeMedia, ViewModeProps, ViewTypeOptions } from "./interface";
|
||||
import ViewModeStorage from "./view_mode_storage";
|
||||
import ViewModeStorage, { type ViewModeStorageType } from "./view_mode_storage";
|
||||
|
||||
interface NoteListProps {
|
||||
note: FNote | null | undefined;
|
||||
notePath: string | null | undefined;
|
||||
@@ -215,7 +216,7 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt
|
||||
return noteIds;
|
||||
}
|
||||
|
||||
export function useViewModeConfig<T extends object>(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined) {
|
||||
export function useViewModeConfig<T extends object>(note: FNote | null | undefined, viewType: ViewModeStorageType | undefined) {
|
||||
const [ viewConfig, setViewConfig ] = useState<{
|
||||
config: T | undefined;
|
||||
storeFn: (data: T) => void;
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import Calendar from "./calendar";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import "./index.css";
|
||||
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
|
||||
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||
|
||||
import { Calendar as FullCalendar } from "@fullcalendar/core";
|
||||
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
|
||||
import dialog from "../../../services/dialog";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { buildEvents, buildEventsForCalendar } from "./event_builder";
|
||||
import { changeEvent, newEvent } from "./api";
|
||||
import froca from "../../../services/froca";
|
||||
import date_notes from "../../../services/date_notes";
|
||||
import appContext from "../../../components/app_context";
|
||||
import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js";
|
||||
import { DateClickArg } from "@fullcalendar/interaction";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import Button, { ButtonGroup } from "../../react/Button";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { RefObject } from "preact";
|
||||
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
|
||||
import { openCalendarContextMenu } from "./context_menu";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import date_notes from "../../../services/date_notes";
|
||||
import dialog from "../../../services/dialog";
|
||||
import froca from "../../../services/froca";
|
||||
import { t } from "../../../services/i18n";
|
||||
import { isMobile } from "../../../services/utils";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import Button, { ButtonGroup } from "../../react/Button";
|
||||
import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
|
||||
import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { changeEvent, newEvent } from "./api";
|
||||
import Calendar from "./calendar";
|
||||
import { openCalendarContextMenu } from "./context_menu";
|
||||
import { buildEvents, buildEventsForCalendar } from "./event_builder";
|
||||
import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
|
||||
|
||||
interface CalendarViewData {
|
||||
|
||||
@@ -59,7 +61,7 @@ const CALENDAR_VIEWS = [
|
||||
previousText: t("calendar.month_previous"),
|
||||
nextText: t("calendar.month_next")
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
const SUPPORTED_CALENDAR_VIEW_TYPE = CALENDAR_VIEWS.map(v => v.type);
|
||||
|
||||
@@ -75,6 +77,7 @@ export const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, (() => Promise<{ de
|
||||
ru: () => import("@fullcalendar/core/locales/ru"),
|
||||
ja: () => import("@fullcalendar/core/locales/ja"),
|
||||
pt: () => import("@fullcalendar/core/locales/pt"),
|
||||
pl: () => import("@fullcalendar/core/locales/pl"),
|
||||
"pt_br": () => import("@fullcalendar/core/locales/pt-br"),
|
||||
uk: () => import("@fullcalendar/core/locales/uk"),
|
||||
en: null,
|
||||
@@ -102,9 +105,9 @@ export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarVi
|
||||
const eventBuilder = useMemo(() => {
|
||||
if (!isCalendarRoot) {
|
||||
return async () => await buildEvents(noteIds);
|
||||
} else {
|
||||
return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e);
|
||||
}
|
||||
}
|
||||
return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e);
|
||||
|
||||
}, [isCalendarRoot, noteIds]);
|
||||
|
||||
const plugins = usePlugins(isEditable, isCalendarRoot);
|
||||
@@ -178,7 +181,7 @@ function CalendarHeader({ calendarRef }: { calendarRef: RefObject<FullCalendar>
|
||||
<ActionButton icon="bx bx-chevron-right" text={currentViewData?.nextText ?? ""} frame onClick={() => calendarRef.current?.next()} />
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function usePlugins(isEditable: boolean, isCalendarRoot: boolean) {
|
||||
@@ -293,7 +296,7 @@ function useEventDisplayCustomization(parentNote: FNote) {
|
||||
if (promotedAttributes) {
|
||||
let promotedAttributesHtml = "";
|
||||
for (const [name, value] of promotedAttributes) {
|
||||
promotedAttributesHtml = promotedAttributesHtml + /*html*/`\
|
||||
promotedAttributesHtml = `${promotedAttributesHtml /*html*/}\
|
||||
<div class="promoted-attribute">
|
||||
<span class="promoted-attribute-name">${name}</span>: <span class="promoted-attribute-value">${value}</span>
|
||||
</div>`;
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.geo-map-container .leaflet-div-icon .bx {
|
||||
.geo-map-container .leaflet-div-icon .tn-icon {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
inset-inline-start: 2px;
|
||||
|
||||
@@ -142,4 +142,10 @@
|
||||
border: 1px solid var(--main-border-color);
|
||||
background: var(--more-accented-background-color);
|
||||
}
|
||||
|
||||
.note-list.grid-view .note-path {
|
||||
margin-left: 0.5em;
|
||||
vertical-align: middle;
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
@@ -7,7 +7,6 @@ import attribute_renderer from "../../../services/attribute_renderer";
|
||||
import content_renderer from "../../../services/content_renderer";
|
||||
import { t } from "../../../services/i18n";
|
||||
import link from "../../../services/link";
|
||||
import tree from "../../../services/tree";
|
||||
import { useImperativeSearchHighlighlighting, useNoteLabel, useNoteLabelBoolean } from "../../react/hooks";
|
||||
import Icon from "../../react/Icon";
|
||||
import NoteLink from "../../react/NoteLink";
|
||||
@@ -103,16 +102,7 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
|
||||
}
|
||||
|
||||
function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) {
|
||||
const titleRef = useRef<HTMLSpanElement>(null);
|
||||
const [ noteTitle, setNoteTitle ] = useState<string>();
|
||||
const notePath = getNotePath(parentNote, note);
|
||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||
|
||||
useEffect(() => {
|
||||
tree.getNoteTitle(note.noteId, parentNote.noteId).then(setNoteTitle);
|
||||
}, [ note ]);
|
||||
|
||||
useEffect(() => highlightSearch(titleRef.current), [ noteTitle, highlightedTokens ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -123,7 +113,7 @@ function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, pa
|
||||
>
|
||||
<h5 className="note-book-header">
|
||||
<Icon className="note-icon" icon={note.getIcon()} />
|
||||
<span ref={titleRef} className="note-book-title">{noteTitle}</span>
|
||||
<NoteLink className="note-book-title" notePath={notePath} noPreview showNotePath={parentNote.type === "search"} highlightedTokens={highlightedTokens} />
|
||||
<NoteAttributes note={note} />
|
||||
</h5>
|
||||
<NoteContent
|
||||
|
||||
@@ -74,11 +74,11 @@ describe("Presentation model", () => {
|
||||
});
|
||||
|
||||
it("rewrites links to other slides", () => {
|
||||
expect(data.slides[1].content.__html).toStrictEqual(`<div class="ck-content"><p>Go to <a class="reference-link" href="#/slide-slide1"><span><span class="bx bx-folder"></span>First slide</span></a>.</p></div>`);
|
||||
expect(data.slides[1].verticalSlides![0].content.__html).toStrictEqual(`<div class="ck-content"><p>Go to <a class="reference-link" href="#/slide-slide2"><span><span class="bx bx-note"></span>First-sub</span></a>.</p></div>`);
|
||||
expect(data.slides[1].content.__html).toStrictEqual(`<div class="ck-content"><p>Go to <a class="reference-link" href="#/slide-slide1"><span><span class="tn-icon bx bx-folder"></span>First slide</span></a>.</p></div>`);
|
||||
expect(data.slides[1].verticalSlides![0].content.__html).toStrictEqual(`<div class="ck-content"><p>Go to <a class="reference-link" href="#/slide-slide2"><span><span class="tn-icon bx bx-note"></span>First-sub</span></a>.</p></div>`);
|
||||
});
|
||||
|
||||
it("rewrites links even if they are not part of the slideshow", () => {
|
||||
expect(data.slides[0].verticalSlides![0].content.__html).toStrictEqual(`<div class="ck-content"><p>Go to <a class="reference-link" href="#/slide-other"><span><span class="bx bx-note"></span>Other note</span></a>.</p></div>`);
|
||||
expect(data.slides[0].verticalSlides![0].content.__html).toStrictEqual(`<div class="ck-content"><p>Go to <a class="reference-link" href="#/slide-other"><span><span class="tn-icon bx bx-note"></span>Other note</span></a>.</p></div>`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
|
||||
import { LabelType } from "../../../services/promoted_attribute_definition_parser.js";
|
||||
import { LabelType } from "@triliumnext/commons";
|
||||
import { JSX } from "preact";
|
||||
import { renderReactWidget } from "../../react/react_utils.jsx";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { CellComponent, ColumnDefinition, EmptyCallback, FormatterParams, ValueBooleanCallback, ValueVoidCallback } from "tabulator-tables";
|
||||
|
||||
import froca from "../../../services/froca.js";
|
||||
import Icon from "../../react/Icon.jsx";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete.jsx";
|
||||
import { renderReactWidget } from "../../react/react_utils.jsx";
|
||||
|
||||
type ColumnType = LabelType | "relation";
|
||||
|
||||
@@ -78,7 +79,7 @@ export function buildColumnDefinitions({ info, movableRows, existingColumnData,
|
||||
rowHandle: movableRows,
|
||||
width: calculateIndexColumnWidth(rowNumberHint, movableRows),
|
||||
formatter: wrapFormatter(({ cell, formatterParams }) => <div>
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded"></span>{" "}</>}
|
||||
{(formatterParams as RowNumberFormatterParams).movableRows && <><span class="bx bx-dots-vertical-rounded" />{" "}</>}
|
||||
{cell.getRow().getPosition(true)}
|
||||
</div>),
|
||||
formatterParams: { movableRows } satisfies RowNumberFormatterParams
|
||||
@@ -200,14 +201,14 @@ function wrapEditor(Component: (opts: EditorOpts) => JSX.Element): ((
|
||||
editorParams: {},
|
||||
) => HTMLElement | false) {
|
||||
return (cell, _, success, cancel, editorParams) => {
|
||||
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />
|
||||
const elWithParams = <Component cell={cell} success={success} cancel={cancel} editorParams={editorParams} />;
|
||||
return renderReactWidget(null, elWithParams)[0];
|
||||
};
|
||||
}
|
||||
|
||||
function NoteFormatter({ cell }: FormatterOpts) {
|
||||
const noteId = cell.getValue();
|
||||
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null)
|
||||
const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId || note?.noteId === noteId) return;
|
||||
@@ -231,5 +232,5 @@ function RelationEditor({ cell, success }: EditorOpts) {
|
||||
hideAllButtons: true
|
||||
}}
|
||||
noteIdChanged={success}
|
||||
/>
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ import { ViewTypeOptions } from "../collections/interface";
|
||||
|
||||
const ATTACHMENT_ROLE = "viewConfig";
|
||||
|
||||
export type ViewModeStorageType = ViewTypeOptions | "pdfHistory";
|
||||
|
||||
export default class ViewModeStorage<T extends object> {
|
||||
|
||||
private note: FNote;
|
||||
private attachmentName: string;
|
||||
|
||||
constructor(note: FNote, viewType: ViewTypeOptions) {
|
||||
constructor(note: FNote, viewType: ViewModeStorageType) {
|
||||
this.note = note;
|
||||
this.attachmentName = viewType + ".json";
|
||||
this.attachmentName = `${viewType}.json`;
|
||||
}
|
||||
|
||||
async store(data: T) {
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
overflow: auto;
|
||||
scroll-behavior: smooth;
|
||||
position: relative;
|
||||
|
||||
> .inline-title,
|
||||
> .note-detail > .note-detail-editable-text,
|
||||
> .note-list-widget:not(.full-height) {
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.note-split.type-code:not(.mime-text-x-sqlite) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import FlexContainer from "./flex_container.js";
|
||||
import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import Component from "../../components/component.js";
|
||||
import NoteContext from "../../components/note_context.js";
|
||||
import splitService from "../../services/resizer.js";
|
||||
import { isMobile } from "../../services/utils.js";
|
||||
import NoteContext from "../../components/note_context.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import FlexContainer from "./flex_container.js";
|
||||
|
||||
interface SplitNoteWidget extends BasicWidget {
|
||||
hasBeenAlreadyShown?: boolean;
|
||||
@@ -74,7 +75,7 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
||||
|
||||
|
||||
const subContexts = activeContext.getSubContexts();
|
||||
let noteContext: NoteContext | undefined = undefined;
|
||||
let noteContext: NoteContext | undefined;
|
||||
if (isMobile() && subContexts.length > 1) {
|
||||
noteContext = subContexts.find(s => s.ntxId !== ntxId);
|
||||
}
|
||||
@@ -201,6 +202,11 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
||||
|
||||
async refresh() {
|
||||
this.toggleExt(true);
|
||||
|
||||
// Mark the active note context.
|
||||
for (const child of this.children as NoteContextAwareWidget[]) {
|
||||
child.$widget.toggleClass("active", !!child.noteContext?.isActive());
|
||||
}
|
||||
}
|
||||
|
||||
toggleInt(show: boolean) {} // not needed
|
||||
@@ -239,16 +245,16 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
||||
widget.hasBeenAlreadyShown = true;
|
||||
|
||||
return [widget.handleEvent("noteSwitched", noteSwitchedContext), this.refreshNotShown(noteSwitchedContext)];
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.resolve();
|
||||
|
||||
}
|
||||
|
||||
if (name === "activeContextChanged") {
|
||||
return this.refreshNotShown(data as EventData<"activeContextChanged">);
|
||||
} else {
|
||||
return super.handleEventInChildren(name, data);
|
||||
}
|
||||
return super.handleEventInChildren(name, data);
|
||||
|
||||
}
|
||||
|
||||
refreshNotShown(data: NoteSwitchedContext | EventData<"activeContextChanged">) {
|
||||
|
||||
@@ -88,6 +88,7 @@ export default function PopupEditor() {
|
||||
onHidden={() => setShown(false)}
|
||||
keepInDom // needed for faster loading
|
||||
noFocus // automatic focus breaks block popup
|
||||
stackable
|
||||
>
|
||||
{!isNewLayout && <ReadOnlyNoteInfoBar />}
|
||||
<PromotedAttributes />
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function RecentChangesDialog() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ancestorNoteId) return;
|
||||
server.get<RecentChangeRow[]>(`recent-changes/${ancestorNoteId}`)
|
||||
.then(async (recentChanges) => {
|
||||
// preload all notes into cache
|
||||
|
||||
@@ -14,7 +14,7 @@ body.mobile .revisions-dialog {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.modal-body {
|
||||
height: fit-content !important;
|
||||
flex-direction: column;
|
||||
@@ -24,7 +24,7 @@ body.mobile .revisions-dialog {
|
||||
.modal-footer {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
|
||||
.revision-list {
|
||||
height: fit-content !important;
|
||||
max-height: 20vh;
|
||||
@@ -32,7 +32,7 @@ body.mobile .revisions-dialog {
|
||||
padding: 0 1em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.modal-body > .revision-content-wrapper {
|
||||
flex-grow: 1;
|
||||
max-width: unset !important;
|
||||
@@ -40,24 +40,68 @@ body.mobile .revisions-dialog {
|
||||
margin: 0;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
|
||||
.modal-body > .revision-content-wrapper > div:first-of-type {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.revision-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.revision-title-buttons {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
.revision-content {
|
||||
padding: 0.5em;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.revisions-dialog {
|
||||
.revision-title-buttons {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.revision-list {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.revision-content.type-file {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-grow: 1;
|
||||
|
||||
.file-preview-table {
|
||||
th,
|
||||
td {
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.revision-file-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.revision-file-preview-content {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> * {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import type { RevisionPojo, RevisionItem } from "@triliumnext/commons";
|
||||
import "./revisions.css";
|
||||
|
||||
import type { RevisionItem, RevisionPojo } from "@triliumnext/commons";
|
||||
import clsx from "clsx";
|
||||
import { diffWords } from "diff";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../components/app_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import dialog from "../../services/dialog";
|
||||
import froca from "../../services/froca";
|
||||
import { t } from "../../services/i18n";
|
||||
import { renderMathInElement } from "../../services/math";
|
||||
import open from "../../services/open";
|
||||
import options from "../../services/options";
|
||||
import protected_session_holder from "../../services/protected_session_holder";
|
||||
import server from "../../services/server";
|
||||
import toast from "../../services/toast";
|
||||
import Button from "../react/Button";
|
||||
import FormToggle from "../react/FormToggle";
|
||||
import Modal from "../react/Modal";
|
||||
import FormList, { FormListItem } from "../react/FormList";
|
||||
import utils from "../../services/utils";
|
||||
import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks";
|
||||
import protected_session_holder from "../../services/protected_session_holder";
|
||||
import { renderMathInElement } from "../../services/math";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import open from "../../services/open";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import options from "../../services/options";
|
||||
import Button from "../react/Button";
|
||||
import FormList, { FormListItem } from "../react/FormList";
|
||||
import FormToggle from "../react/FormToggle";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { diffWords } from "diff";
|
||||
import "./revisions.css";
|
||||
import Modal from "../react/Modal";
|
||||
import { RawHtmlBlock } from "../react/RawHtml";
|
||||
import PdfViewer from "../type_widgets/file/PdfViewer";
|
||||
|
||||
export default function RevisionsDialog() {
|
||||
const [ note, setNote ] = useState<FNote>();
|
||||
@@ -47,7 +52,7 @@ export default function RevisionsDialog() {
|
||||
setRevisions(undefined);
|
||||
setNoteContent(undefined);
|
||||
}
|
||||
}, [ note?.noteId, refreshCounter ]);
|
||||
}, [ note, refreshCounter ]);
|
||||
|
||||
if (revisions?.length && !currentRevision) {
|
||||
setCurrentRevision(revisions[0]);
|
||||
@@ -102,38 +107,38 @@ export default function RevisionsDialog() {
|
||||
setRevisions(undefined);
|
||||
}}
|
||||
show={shown}
|
||||
>
|
||||
<RevisionsList
|
||||
revisions={revisions ?? []}
|
||||
onSelect={(revisionId) => {
|
||||
const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
|
||||
if (correspondingRevision) {
|
||||
setCurrentRevision(correspondingRevision);
|
||||
}
|
||||
}}
|
||||
currentRevision={currentRevision}
|
||||
/>
|
||||
>
|
||||
<RevisionsList
|
||||
revisions={revisions ?? []}
|
||||
onSelect={(revisionId) => {
|
||||
const correspondingRevision = (revisions ?? []).find((r) => r.revisionId === revisionId);
|
||||
if (correspondingRevision) {
|
||||
setCurrentRevision(correspondingRevision);
|
||||
}
|
||||
}}
|
||||
currentRevision={currentRevision}
|
||||
/>
|
||||
|
||||
<div className="revision-content-wrapper" style={{
|
||||
flexGrow: "1",
|
||||
marginInlineStart: "20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxWidth: "calc(100% - 150px)",
|
||||
minWidth: 0
|
||||
}}>
|
||||
<RevisionPreview
|
||||
noteContent={noteContent}
|
||||
revisionItem={currentRevision}
|
||||
showDiff={showDiff}
|
||||
setShown={setShown}
|
||||
onRevisionDeleted={() => {
|
||||
setRefreshCounter(c => c + 1);
|
||||
setCurrentRevision(undefined);
|
||||
}} />
|
||||
</div>
|
||||
<div className="revision-content-wrapper" style={{
|
||||
flexGrow: "1",
|
||||
marginInlineStart: "20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxWidth: "calc(100% - 150px)",
|
||||
minWidth: 0
|
||||
}}>
|
||||
<RevisionPreview
|
||||
noteContent={noteContent}
|
||||
revisionItem={currentRevision}
|
||||
showDiff={showDiff}
|
||||
setShown={setShown}
|
||||
onRevisionDeleted={() => {
|
||||
setRefreshCounter(c => c + 1);
|
||||
setCurrentRevision(undefined);
|
||||
}} />
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) {
|
||||
@@ -141,6 +146,7 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
|
||||
<FormList onSelect={onSelect} fullHeight wrapperClassName="revision-list">
|
||||
{revisions.map((item) =>
|
||||
<FormListItem
|
||||
key={item.revisionId}
|
||||
value={item.revisionId}
|
||||
active={currentRevision && item.revisionId === currentRevision.revisionId}
|
||||
>
|
||||
@@ -202,14 +208,17 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
|
||||
text={t("revisions.download_button")}
|
||||
onClick={() => {
|
||||
if (revisionItem.revisionId) {
|
||||
open.downloadRevision(revisionItem.noteId, revisionItem.revisionId)}
|
||||
}
|
||||
open.downloadRevision(revisionItem.noteId, revisionItem.revisionId);}
|
||||
}
|
||||
}/>
|
||||
</>
|
||||
}
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="revision-content use-tn-links selectable-text" style={{ overflow: "auto", wordBreak: "break-word" }}>
|
||||
<div
|
||||
className={clsx("revision-content use-tn-links selectable-text", `type-${revisionItem?.type}`)}
|
||||
style={{ overflow: "auto", wordBreak: "break-word" }}
|
||||
>
|
||||
<RevisionContent noteContent={noteContent} revisionItem={revisionItem} fullRevision={fullRevision} showDiff={showDiff}/>
|
||||
</div>
|
||||
</>
|
||||
@@ -230,16 +239,16 @@ const CODE_STYLE: CSSProperties = {
|
||||
|
||||
function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }: { noteContent?:string, revisionItem?: RevisionItem, fullRevision?: RevisionPojo, showDiff: boolean}) {
|
||||
const content = fullRevision?.content;
|
||||
if (!revisionItem || !content) {
|
||||
if (!revisionItem || !fullRevision) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (showDiff) {
|
||||
return <RevisionContentDiff noteContent={noteContent} itemContent={content} itemType={revisionItem.type}/>
|
||||
return <RevisionContentDiff noteContent={noteContent} itemContent={content} itemType={revisionItem.type}/>;
|
||||
}
|
||||
switch (revisionItem.type) {
|
||||
case "text":
|
||||
return <RevisionContentText content={content} />
|
||||
return <RevisionContentText content={content} />;
|
||||
case "code":
|
||||
return <pre style={CODE_STYLE}>{content}</pre>;
|
||||
case "image":
|
||||
@@ -256,28 +265,11 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
|
||||
// as a URL to be used in a note. Instead, if they copy and paste it into a note, it will be uploaded as a new note
|
||||
return <img
|
||||
src={`data:${fullRevision.mime};base64,${fullRevision.content}`}
|
||||
style={IMAGE_STYLE} />
|
||||
style={IMAGE_STYLE} />;
|
||||
}
|
||||
}
|
||||
case "file":
|
||||
return <table cellPadding="10">
|
||||
<tr>
|
||||
<th>{t("revisions.mime")}</th>
|
||||
<td>{revisionItem.mime}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("revisions.file_size")}</th>
|
||||
<td>{revisionItem.contentLength && utils.formatSize(revisionItem.contentLength)}</td>
|
||||
</tr>
|
||||
{fullRevision.content &&
|
||||
<tr>
|
||||
<td colspan={2}>
|
||||
<strong>{t("revisions.preview")}</strong>
|
||||
<pre className="file-preview-content" style={CODE_STYLE}>{fullRevision.content}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>;
|
||||
return <FilePreview fullRevision={fullRevision} revisionItem={revisionItem} />;
|
||||
case "canvas":
|
||||
case "mindMap":
|
||||
case "mermaid": {
|
||||
@@ -287,7 +279,7 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
|
||||
style={IMAGE_STYLE} />;
|
||||
}
|
||||
default:
|
||||
return <>{t("revisions.preview_not_available")}</>
|
||||
return <>{t("revisions.preview_not_available")}</>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +290,7 @@ function RevisionContentText({ content }: { content: string | Buffer<ArrayBuffer
|
||||
renderMathInElement(contentRef.current, { trust: true });
|
||||
}
|
||||
}, [content]);
|
||||
return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div>
|
||||
return <RawHtmlBlock containerRef={contentRef} className="ck-content" html={content as string} />;
|
||||
}
|
||||
|
||||
function RevisionContentDiff({ noteContent, itemContent, itemType }: {
|
||||
@@ -330,9 +322,9 @@ function RevisionContentDiff({ noteContent, itemContent, itemType }: {
|
||||
return `<span class="revision-diff-added">${utils.escapeHtml(part.value)}</span>`;
|
||||
} else if (part.removed) {
|
||||
return `<span class="revision-diff-removed">${utils.escapeHtml(part.value)}</span>`;
|
||||
} else {
|
||||
return utils.escapeHtml(part.value);
|
||||
}
|
||||
return utils.escapeHtml(part.value);
|
||||
|
||||
}).join("");
|
||||
|
||||
if (contentRef.current) {
|
||||
@@ -340,7 +332,7 @@ function RevisionContentDiff({ noteContent, itemContent, itemType }: {
|
||||
}
|
||||
}, [noteContent, itemContent, itemType]);
|
||||
|
||||
return <div ref={contentRef} className="ck-content" style={{ whiteSpace: "pre-wrap" }}></div>;
|
||||
return <div ref={contentRef} className="ck-content" style={{ whiteSpace: "pre-wrap" }} />;
|
||||
}
|
||||
|
||||
function RevisionFooter({ note }: { note?: FNote }) {
|
||||
@@ -348,7 +340,7 @@ function RevisionFooter({ note }: { note?: FNote }) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
let revisionsNumberLimit: number | string = parseInt(note?.getLabelValue("versioningLimit") ?? "");
|
||||
let revisionsNumberLimit: number | string = parseInt(note?.getLabelValue("versioningLimit") ?? "", 10);
|
||||
if (!Number.isInteger(revisionsNumberLimit)) {
|
||||
revisionsNumberLimit = options.getInt("revisionSnapshotNumberLimit") ?? 0;
|
||||
}
|
||||
@@ -370,10 +362,67 @@ function RevisionFooter({ note }: { note?: FNote }) {
|
||||
</>;
|
||||
}
|
||||
|
||||
function FilePreview({ revisionItem, fullRevision }: { revisionItem: RevisionItem, fullRevision: RevisionPojo }) {
|
||||
return (
|
||||
<div className="revision-file-preview">
|
||||
<table className="file-preview-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{t("revisions.mime")}</th>
|
||||
<td>{revisionItem.mime}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{t("revisions.file_size")}</th>
|
||||
<td>{revisionItem.contentLength && utils.formatSize(revisionItem.contentLength)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="revision-file-preview-content">
|
||||
<FilePreviewInner revisionItem={revisionItem} fullRevision={fullRevision} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePreviewInner({ revisionItem, fullRevision }: { revisionItem: RevisionItem, fullRevision: RevisionPojo }) {
|
||||
if (revisionItem.mime.startsWith("audio/")) {
|
||||
return (
|
||||
<audio
|
||||
src={`api/revisions/${revisionItem.revisionId}/download`}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (revisionItem.mime.startsWith("video/")) {
|
||||
return (
|
||||
<video
|
||||
src={`api/revisions/${revisionItem.revisionId}/download`}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (revisionItem.mime === "application/pdf") {
|
||||
return (
|
||||
<PdfViewer
|
||||
pdfUrl={`../../api/revisions/${revisionItem.revisionId}/download`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fullRevision.content) {
|
||||
return <pre className="file-preview-content" style={CODE_STYLE}>{fullRevision.content}</pre>;
|
||||
}
|
||||
|
||||
return t("revisions.preview_not_available");
|
||||
}
|
||||
|
||||
async function getNote(noteId?: string | null) {
|
||||
if (noteId) {
|
||||
return await froca.getNote(noteId);
|
||||
} else {
|
||||
return appContext.tabManager.getActiveContextNote();
|
||||
}
|
||||
return appContext.tabManager.getActiveContextNote();
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
|
||||
.bx {
|
||||
.tn-icon {
|
||||
margin-inline: 6px;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
.icon-action {
|
||||
font-size: .9rem !important;
|
||||
|
||||
.bxs-chevron-right {
|
||||
&.breadcrumb-separator {
|
||||
transform: translateY(8%);
|
||||
|
||||
&::before {
|
||||
|
||||
@@ -191,7 +191,7 @@ function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) {
|
||||
<Dropdown
|
||||
text={<Icon icon="bx bxs-chevron-right" />}
|
||||
noSelectButtonStyle
|
||||
buttonClassName="icon-action"
|
||||
buttonClassName="icon-action breadcrumb-separator"
|
||||
hideToggleArrow
|
||||
dropdownContainerClassName="tn-dropdown-menu-scrollable breadcrumb-child-list"
|
||||
dropdownOptions={{ popperConfig: { strategy: "fixed", placement: "top" } }}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
max-width: var(--max-content-width);
|
||||
container-type: inline-size;
|
||||
padding-top: 20px;
|
||||
padding-inline-start: 24px;
|
||||
|
||||
& > .inline-title-row {
|
||||
--icon-size: 35px;
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
contain: none;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: var(--default-opacity); }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.note-badges {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
@@ -16,6 +21,23 @@
|
||||
&.share-badge {--color: var(--badge-share-background-color);}
|
||||
&.clipped-note-badge {--color: var(--badge-clipped-note-background-color);}
|
||||
&.execute-badge {--color: var(--badge-execute-background-color);}
|
||||
&.save-status-badge {
|
||||
--default-opacity: 0.4;
|
||||
opacity: var(--default-opacity);
|
||||
transition: opacity 250ms ease-in;
|
||||
color: var(--main-text-color);
|
||||
|
||||
&.error {
|
||||
color: var(--dropdown-item-icon-destructive-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.saved {
|
||||
animation: fadeOut 250ms ease-in 5s forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
min-width: 0;
|
||||
|
||||
.text {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import "./NoteBadges.css";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import { copyTextWithToast } from "../../services/clipboard_ext";
|
||||
import { t } from "../../services/i18n";
|
||||
import { goToLinkExt } from "../../services/link";
|
||||
import { Badge, BadgeWithDropdown } from "../react/Badge";
|
||||
import { FormDropdownDivider, FormListItem } from "../react/FormList";
|
||||
import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean } from "../react/hooks";
|
||||
import { useGetContextData, useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean } from "../react/hooks";
|
||||
import { useShareState } from "../ribbon/BasicPropertiesTab";
|
||||
import { useShareInfo } from "../shared_info";
|
||||
|
||||
export default function NoteBadges() {
|
||||
return (
|
||||
<div className="note-badges">
|
||||
<SaveStatusBadge />
|
||||
<ReadOnlyBadge />
|
||||
<ShareBadge />
|
||||
<ClippedNoteBadge />
|
||||
@@ -105,3 +108,42 @@ function ExecuteBadge() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SaveStatusBadge() {
|
||||
const saveState = useGetContextData("saveState");
|
||||
if (!saveState) return;
|
||||
|
||||
const stateConfig = {
|
||||
saved: {
|
||||
icon: "bx bx-check",
|
||||
title: t("breadcrumb_badges.save_status_saved"),
|
||||
tooltip: undefined
|
||||
},
|
||||
saving: {
|
||||
icon: "bx bx-loader bx-spin",
|
||||
title: t("breadcrumb_badges.save_status_saving"),
|
||||
tooltip: t("breadcrumb_badges.save_status_saving_tooltip")
|
||||
},
|
||||
unsaved: {
|
||||
icon: "bx bx-pencil",
|
||||
title: t("breadcrumb_badges.save_status_unsaved"),
|
||||
tooltip: t("breadcrumb_badges.save_status_unsaved_tooltip")
|
||||
},
|
||||
error: {
|
||||
icon: "bx bxs-error",
|
||||
title: t("breadcrumb_badges.save_status_error"),
|
||||
tooltip: t("breadcrumb_badges.save_status_error_tooltip")
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, title, tooltip } = stateConfig[saveState.state];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={clsx("save-status-badge", saveState.state)}
|
||||
icon={icon}
|
||||
text={title}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,21 @@ body.experimental-feature-new-layout {
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
--title-actions-padding-start: 12px;
|
||||
--title-actions-padding-end: 8px;
|
||||
|
||||
display: flex;
|
||||
max-width: var(--max-content-width);
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
padding-inline: var(--title-actions-padding-start) var(--title-actions-padding-end);
|
||||
|
||||
body.prefers-centered-content .note-split:not(.full-content-width) & {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
&:not(:empty) {
|
||||
padding: 0.75em 15px;
|
||||
padding-block: 0.75em;
|
||||
}
|
||||
|
||||
.edited-notes {
|
||||
@@ -40,5 +49,11 @@ body.experimental-feature-new-layout {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .collapsible,
|
||||
> .note-type-switcher {
|
||||
padding-inline-start: calc(24px - var(--title-actions-padding-start));
|
||||
padding-inline-end: calc(24px - var(--title-actions-padding-end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user