mirror of
https://github.com/zadam/trilium.git
synced 2026-05-07 07:37:24 +02:00
Compare commits
2001 Commits
autocomple
...
standalone
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb5e4fdd37 | ||
|
|
ca4e10bd66 | ||
|
|
a6ddf7dd89 | ||
|
|
4a52bae7b9 | ||
|
|
d90f7f2b3c | ||
|
|
4db5d86528 | ||
|
|
706a75b867 | ||
|
|
c3488c0039 | ||
|
|
8e1e1618ae | ||
|
|
e551e77bb0 | ||
|
|
bb9ff4fa15 | ||
|
|
849afaa877 | ||
|
|
8c3cbf34a8 | ||
|
|
70c42fabfb | ||
|
|
0e678a6870 | ||
|
|
07608a041b | ||
|
|
4914ff7601 | ||
|
|
97438ed46f | ||
|
|
f4790e59eb | ||
|
|
89d89318a0 | ||
|
|
e956d951a8 | ||
|
|
a65f9cb12e | ||
|
|
4f9304c816 | ||
|
|
9295c8646b | ||
|
|
124603c2b3 | ||
|
|
bf6a2f2f32 | ||
|
|
6625c60599 | ||
|
|
c703575449 | ||
|
|
a822f253cd | ||
|
|
d6070da5d6 | ||
|
|
8b44e7cc61 | ||
|
|
ed54a7daca | ||
|
|
7141f2380c | ||
|
|
fbbeee9e4d | ||
|
|
6a2e48dacb | ||
|
|
9ac3fcb21c | ||
|
|
095d287729 | ||
|
|
132c72536a | ||
|
|
e64608d92e | ||
|
|
b43841157e | ||
|
|
d97818a594 | ||
|
|
28c6826b35 | ||
|
|
f48509ea2f | ||
|
|
e30f61b41f | ||
|
|
fb85af44e2 | ||
|
|
24d0b3f6b1 | ||
|
|
1d0bc138fd | ||
|
|
326c0234ff | ||
|
|
bbbacf7f78 | ||
|
|
fd428a81e0 | ||
|
|
ba0e9e2eaa | ||
|
|
286f412906 | ||
|
|
4708d55a99 | ||
|
|
5952d13498 | ||
|
|
6d39bd91d9 | ||
|
|
b97fd784ea | ||
|
|
e7bbdf8cc1 | ||
|
|
fe334ca8eb | ||
|
|
9e37a803f7 | ||
|
|
7fd50a2c6e | ||
|
|
1e4c6eb12c | ||
|
|
c4d832eebc | ||
|
|
c3b1cfd7a5 | ||
|
|
f44a1f690a | ||
|
|
2b07e880c7 | ||
|
|
5921d1f1a6 | ||
|
|
4162806288 | ||
|
|
735c1128e4 | ||
|
|
c0e01becb6 | ||
|
|
b04945e793 | ||
|
|
2c744122ca | ||
|
|
adc648d277 | ||
|
|
a403aca054 | ||
|
|
e7f85bb447 | ||
|
|
e6e6c1feff | ||
|
|
415d9364de | ||
|
|
8a2b649714 | ||
|
|
9c655baab9 | ||
|
|
ae6f5fad3b | ||
|
|
a43cecb0f0 | ||
|
|
f9baac34cc | ||
|
|
0f68993605 | ||
|
|
c995c15eae | ||
|
|
3a3f49e21a | ||
|
|
2a9976cfbb | ||
|
|
417228ebde | ||
|
|
9d1f831737 | ||
|
|
3839dad55d | ||
|
|
900f308349 | ||
|
|
4ae3a00464 | ||
|
|
bc21fd560f | ||
|
|
ab1d1f97c8 | ||
|
|
9a85d7ec26 | ||
|
|
f702930731 | ||
|
|
f449fffaf5 | ||
|
|
78777e69fc | ||
|
|
2d7df2d079 | ||
|
|
fbbdb6e6e6 | ||
|
|
797b5ffd80 | ||
|
|
04954a46d0 | ||
|
|
fafa080cfc | ||
|
|
62e30d76fb | ||
|
|
ca78285fea | ||
|
|
7a47928b82 | ||
|
|
63e2a33543 | ||
|
|
caf53877e3 | ||
|
|
e9b1b2de21 | ||
|
|
9d8d7bd496 | ||
|
|
4579e4d31b | ||
|
|
8d41ba85a8 | ||
|
|
4e55b29041 | ||
|
|
ac1bcefe80 | ||
|
|
1d8a8107af | ||
|
|
36270397c2 | ||
|
|
b349922add | ||
|
|
dbea88fa56 | ||
|
|
b2bcccb4c7 | ||
|
|
9d60bb804d | ||
|
|
189867ca03 | ||
|
|
23cb66bba9 | ||
|
|
10a2e21636 | ||
|
|
2fa0a4b35c | ||
|
|
8d20fec5b8 | ||
|
|
1d25d79a3f | ||
|
|
c61c6ae64c | ||
|
|
fb3c487c0d | ||
|
|
087fc5f2c4 | ||
|
|
e01d835709 | ||
|
|
662d6fa91c | ||
|
|
03a4e1d4c1 | ||
|
|
9d953ea700 | ||
|
|
da96d2bc88 | ||
|
|
dabbbbb874 | ||
|
|
ffe60580db | ||
|
|
a2cd75c2c4 | ||
|
|
f4cf8829f7 | ||
|
|
fb5520a3c5 | ||
|
|
bd30e9efc1 | ||
|
|
f9310c5cde | ||
|
|
01c57fc8db | ||
|
|
f22eaee4e2 | ||
|
|
2742df7cd1 | ||
|
|
c031a99fc0 | ||
|
|
83d08d890d | ||
|
|
e2c79ff047 | ||
|
|
e624c204ff | ||
|
|
62a8fb2228 | ||
|
|
7473d50711 | ||
|
|
41a90f1904 | ||
|
|
ae6803d711 | ||
|
|
81dfc59846 | ||
|
|
d1528a2f3a | ||
|
|
36f635b7d5 | ||
|
|
6278a376b7 | ||
|
|
20fca6c0df | ||
|
|
1ab04cf951 | ||
|
|
3ece5d6213 | ||
|
|
65aba291ca | ||
|
|
b3e877f5dd | ||
|
|
066be7fa33 | ||
|
|
6390f59fcf | ||
|
|
81ea86100f | ||
|
|
d086c8664b | ||
|
|
03d6c81659 | ||
|
|
270f135632 | ||
|
|
6436e56448 | ||
|
|
315a97701b | ||
|
|
927c359716 | ||
|
|
fc5252e6b8 | ||
|
|
69e757459f | ||
|
|
62189cfa04 | ||
|
|
4b35881889 | ||
|
|
1c508b830e | ||
|
|
e5f97b6fdd | ||
|
|
0bdebca2b6 | ||
|
|
7dfdc7f31a | ||
|
|
ec7b9e08e3 | ||
|
|
a915c60c38 | ||
|
|
676a988433 | ||
|
|
15951886bd | ||
|
|
8aaa4d7bde | ||
|
|
a439e7c29b | ||
|
|
85bd4790ac | ||
|
|
4876d4b8c6 | ||
|
|
81a54d8398 | ||
|
|
8e91a9eb0e | ||
|
|
2a7309477c | ||
|
|
837cb37642 | ||
|
|
c69e3b7e14 | ||
|
|
7c3bb8c589 | ||
|
|
2c6c7cb037 | ||
|
|
8446e98b21 | ||
|
|
96ddb79d06 | ||
|
|
4244b66cea | ||
|
|
15c121f950 | ||
|
|
683814c9d2 | ||
|
|
b01feed4a2 | ||
|
|
3ce2af9abe | ||
|
|
7219fc875d | ||
|
|
131e10f4fe | ||
|
|
8cc5e0282e | ||
|
|
426d5daf73 | ||
|
|
480da09bcc | ||
|
|
ae004c4334 | ||
|
|
4dcbd36b2d | ||
|
|
5539c901fe | ||
|
|
84fff30723 | ||
|
|
bdf4e40577 | ||
|
|
79d639108b | ||
|
|
5b957dd111 | ||
|
|
c0b1ff31e5 | ||
|
|
cc010e1568 | ||
|
|
d240fb32bb | ||
|
|
374eeaeb08 | ||
|
|
1674bf0a87 | ||
|
|
a617b59765 | ||
|
|
e5daa75cb4 | ||
|
|
a9e7cd7bfe | ||
|
|
84d46c0a29 | ||
|
|
036d09f99e | ||
|
|
6c2ce8f39c | ||
|
|
77b89c5a01 | ||
|
|
65d3224c1a | ||
|
|
7d6fd54562 | ||
|
|
1a07dff373 | ||
|
|
e99cf74988 | ||
|
|
1acbf5ba45 | ||
|
|
64bc1271e1 | ||
|
|
06f7818ee3 | ||
|
|
a0548e1627 | ||
|
|
2639c5a225 | ||
|
|
bfc22864f6 | ||
|
|
69763c8b14 | ||
|
|
75b4877c87 | ||
|
|
9ff466c9f5 | ||
|
|
5f32d83c79 | ||
|
|
eb3adfa733 | ||
|
|
80aac52066 | ||
|
|
0335ece20b | ||
|
|
e2d6fdb09a | ||
|
|
79ea95cb39 | ||
|
|
84bc385bab | ||
|
|
1af2a07a57 | ||
|
|
98b92b7220 | ||
|
|
9b94d232a8 | ||
|
|
fef8b6f58e | ||
|
|
7bb15029cf | ||
|
|
c328b858a4 | ||
|
|
c2d5ba8e52 | ||
|
|
aa7615e72e | ||
|
|
e0c951e758 | ||
|
|
9b1b0c5574 | ||
|
|
1b4400db03 | ||
|
|
9a3d160c34 | ||
|
|
8457c22be3 | ||
|
|
fb5f89108f | ||
|
|
b6144f3d09 | ||
|
|
5fe6611a91 | ||
|
|
8760d683a3 | ||
|
|
07a22722ae | ||
|
|
9565c398f8 | ||
|
|
d22b0079dc | ||
|
|
dee9959d5b | ||
|
|
3f39e6f7f1 | ||
|
|
f03e527cb0 | ||
|
|
66819cb73f | ||
|
|
9f4c3ed35a | ||
|
|
e33446c219 | ||
|
|
daf5740610 | ||
|
|
9464e2aff5 | ||
|
|
5ef74d5639 | ||
|
|
822e7ffbab | ||
|
|
11b0bec47d | ||
|
|
0c051327cf | ||
|
|
e943891e2f | ||
|
|
438442345e | ||
|
|
d2c51d5175 | ||
|
|
12e07dbfcd | ||
|
|
dbe37730c3 | ||
|
|
0dfbfaa61c | ||
|
|
f1d557645c | ||
|
|
08e3a4aefc | ||
|
|
ff215bd228 | ||
|
|
dc57d6131a | ||
|
|
3417f82e27 | ||
|
|
1ec270dba2 | ||
|
|
6d2496599f | ||
|
|
18d69f94d2 | ||
|
|
b84e50065c | ||
|
|
81c266f69b | ||
|
|
e9d8aa82f4 | ||
|
|
da76d4c50e | ||
|
|
ec7f6a9325 | ||
|
|
28e33e3c58 | ||
|
|
c6ee5fabee | ||
|
|
1048ecb7c0 | ||
|
|
b1af1510f4 | ||
|
|
944d0542b6 | ||
|
|
cee2338c1b | ||
|
|
93db35518a | ||
|
|
63dcdc4e83 | ||
|
|
5a073398ed | ||
|
|
6166a8df43 | ||
|
|
57c3f2df17 | ||
|
|
244d0f5568 | ||
|
|
eb426ca3a7 | ||
|
|
d17634aaa8 | ||
|
|
78efebaeea | ||
|
|
4357901c24 | ||
|
|
8b8a955782 | ||
|
|
5153306a68 | ||
|
|
fc2d171fcf | ||
|
|
ab10a19932 | ||
|
|
3f9b26d42c | ||
|
|
bf9ee98406 | ||
|
|
3dc93fde37 | ||
|
|
8ec6125ec4 | ||
|
|
999bfbc118 | ||
|
|
889e44363a | ||
|
|
53413be08c | ||
|
|
28b1eb71ba | ||
|
|
4bc1d93b75 | ||
|
|
f64b029009 | ||
|
|
d65d7dba3f | ||
|
|
12a83510ed | ||
|
|
b2f02962fc | ||
|
|
262c89d252 | ||
|
|
7f75ab0638 | ||
|
|
30a82f3cfc | ||
|
|
011cce05af | ||
|
|
735712123c | ||
|
|
603b232d1f | ||
|
|
ce836bccc2 | ||
|
|
d8db862c22 | ||
|
|
d2836ef84c | ||
|
|
09d1c85bd3 | ||
|
|
e88cb60711 | ||
|
|
6bc3176251 | ||
|
|
c888826192 | ||
|
|
cd6f63a908 | ||
|
|
c36264203f | ||
|
|
1044776218 | ||
|
|
093e671fb5 | ||
|
|
0e76f412ec | ||
|
|
1154a1f7bf | ||
|
|
062d0863a1 | ||
|
|
8b202b6760 | ||
|
|
2fc907e756 | ||
|
|
5218373f3e | ||
|
|
6221f17beb | ||
|
|
f96f64bd84 | ||
|
|
13cdba199a | ||
|
|
4aa4d4880d | ||
|
|
51f1559473 | ||
|
|
ff9f0ad676 | ||
|
|
7e3683b8c2 | ||
|
|
0df186ef7d | ||
|
|
ff3b6c4011 | ||
|
|
0b5332fbb3 | ||
|
|
f285f7e14f | ||
|
|
794ad9410b | ||
|
|
60f3ddd354 | ||
|
|
20235509ef | ||
|
|
0ece1270c7 | ||
|
|
a32eeb27e3 | ||
|
|
e50ef8552b | ||
|
|
c34df3a17a | ||
|
|
48e90396bd | ||
|
|
d147bbe63d | ||
|
|
a82d9dad1b | ||
|
|
5ad0cb2b4b | ||
|
|
a0e9082364 | ||
|
|
1442041cda | ||
|
|
4b9d253745 | ||
|
|
3c0d5b7614 | ||
|
|
f0293b7f07 | ||
|
|
2bf2d977ad | ||
|
|
7e1090c59d | ||
|
|
a4c419de6f | ||
|
|
263479adee | ||
|
|
c0c0cea376 | ||
|
|
1bf36574e2 | ||
|
|
3ed6297011 | ||
|
|
3bd98d6a1b | ||
|
|
ecc872828c | ||
|
|
9c5d6e0a19 | ||
|
|
98d718031b | ||
|
|
9bd78c5f83 | ||
|
|
598317ea6e | ||
|
|
99878a1d48 | ||
|
|
264021360a | ||
|
|
ae4dc53647 | ||
|
|
6dd51f66ab | ||
|
|
61262e899b | ||
|
|
0ebec2ce5d | ||
|
|
6225f92a7d | ||
|
|
6c9ffe1d80 | ||
|
|
9116cbcba4 | ||
|
|
d0e61e39d0 | ||
|
|
c691a9c6e6 | ||
|
|
94e70c0318 | ||
|
|
edb2ec2a6f | ||
|
|
f68a481edc | ||
|
|
3a1f0b2be1 | ||
|
|
bf5d6c4e01 | ||
|
|
ba13f86f5f | ||
|
|
e7c30927f7 | ||
|
|
634b2b3a84 | ||
|
|
83cb437133 | ||
|
|
37e8514400 | ||
|
|
341a5310e1 | ||
|
|
9e4a5c892e | ||
|
|
307536b70f | ||
|
|
f12d73a5c7 | ||
|
|
b20e2459c3 | ||
|
|
692a31772d | ||
|
|
6a05288be4 | ||
|
|
fc82b9374e | ||
|
|
457bba9337 | ||
|
|
249b08f1ce | ||
|
|
54a6e3d9a1 | ||
|
|
f7a36fc997 | ||
|
|
1952b44141 | ||
|
|
cd84e6ba08 | ||
|
|
454c18ff0e | ||
|
|
4d4e63998f | ||
|
|
2db1e2d750 | ||
|
|
cb03f3cec5 | ||
|
|
b2ec9a2f47 | ||
|
|
71b3ee143f | ||
|
|
22eb2697d5 | ||
|
|
59994a5877 | ||
|
|
c35bc6fbd2 | ||
|
|
2d6c2b2cd0 | ||
|
|
6b6573bf02 | ||
|
|
278da994ce | ||
|
|
ab00772559 | ||
|
|
49e432fb66 | ||
|
|
6f73ea5847 | ||
|
|
743db3a9cd | ||
|
|
b899a0b5f8 | ||
|
|
c6ea68c012 | ||
|
|
9fc750533c | ||
|
|
7d52c62155 | ||
|
|
27801a859a | ||
|
|
25a28abb2d | ||
|
|
591a9a6963 | ||
|
|
f2815de17e | ||
|
|
b61b5100f0 | ||
|
|
51f06f064e | ||
|
|
27ed4165dc | ||
|
|
24167c0691 | ||
|
|
203c725e0c | ||
|
|
9326751923 | ||
|
|
9ba1eeaf20 | ||
|
|
ae676f38d3 | ||
|
|
1765917393 | ||
|
|
02336f4e53 | ||
|
|
3ce508c5ee | ||
|
|
266f542028 | ||
|
|
0f605ba994 | ||
|
|
d4333400c4 | ||
|
|
7180569357 | ||
|
|
b88eaaeeec | ||
|
|
b9e8cd5697 | ||
|
|
92d011503d | ||
|
|
a62a7e351e | ||
|
|
19e07bc6fd | ||
|
|
f9b0a88a4e | ||
|
|
00b85bb7bd | ||
|
|
17fb3f576d | ||
|
|
49878d64aa | ||
|
|
368dd1adc4 | ||
|
|
2e8f6a495b | ||
|
|
2a23b8a868 | ||
|
|
8e56585575 | ||
|
|
80f40a439c | ||
|
|
9d49324557 | ||
|
|
85ebe59a3b | ||
|
|
692d33bedf | ||
|
|
cd0763e807 | ||
|
|
93920bdfb4 | ||
|
|
09fdcb3839 | ||
|
|
6ff7219300 | ||
|
|
7e246f599d | ||
|
|
f422155dee | ||
|
|
05d4d3a1a2 | ||
|
|
d716fec524 | ||
|
|
66347e4bad | ||
|
|
e740e729c3 | ||
|
|
4baa22a01a | ||
|
|
7c1f509eca | ||
|
|
0ead37fd5c | ||
|
|
a33de6454f | ||
|
|
4a9e7c843e | ||
|
|
1f6c88dcc1 | ||
|
|
43d9d726f8 | ||
|
|
c5667d9141 | ||
|
|
7455d235d1 | ||
|
|
3dc0d25c4d | ||
|
|
c352d46a5b | ||
|
|
c262187496 | ||
|
|
0c5a8a24da | ||
|
|
e5a9622720 | ||
|
|
ad864cfe48 | ||
|
|
f58dd12983 | ||
|
|
e40504b7f0 | ||
|
|
301f23cd2d | ||
|
|
885e94cf58 | ||
|
|
6763f4f403 | ||
|
|
ead70ad394 | ||
|
|
597c6eb15b | ||
|
|
47ce77e04c | ||
|
|
6e90a4168e | ||
|
|
9b2be57365 | ||
|
|
5ba7803ea9 | ||
|
|
7c53fe56be | ||
|
|
88743f158b | ||
|
|
f360b1fb55 | ||
|
|
dda9e51a37 | ||
|
|
ef9002dede | ||
|
|
37df2dd9b2 | ||
|
|
7ec5945517 | ||
|
|
0150b5b61e | ||
|
|
d41d727950 | ||
|
|
224c31d16b | ||
|
|
b67bd01858 | ||
|
|
5459ec33df | ||
|
|
59999e5adc | ||
|
|
fc009923e5 | ||
|
|
9303f54026 | ||
|
|
7e30b8c029 | ||
|
|
c1596d2a25 | ||
|
|
2411d7bc76 | ||
|
|
a44ff4c78b | ||
|
|
5cd1ffb7a5 | ||
|
|
f9aaccdfe2 | ||
|
|
cae3d14804 | ||
|
|
60540c37f2 | ||
|
|
113a962500 | ||
|
|
4c02d70dae | ||
|
|
8c2e2cc9ba | ||
|
|
e6db4a51d1 | ||
|
|
d3c927ed88 | ||
|
|
1fdc623ebc | ||
|
|
91d4e77a48 | ||
|
|
395c71fa0d | ||
|
|
de037b3ced | ||
|
|
8f3f2cc8c1 | ||
|
|
4b4ef35272 | ||
|
|
3b437d85c8 | ||
|
|
262ac05483 | ||
|
|
f75adfe6a3 | ||
|
|
3a8f0e0fcd | ||
|
|
b46c1e6d57 | ||
|
|
9f24a44e15 | ||
|
|
89b3dec84a | ||
|
|
3ad20e43f1 | ||
|
|
f034454ec9 | ||
|
|
35317b3dab | ||
|
|
0d5c9986b6 | ||
|
|
745374050e | ||
|
|
b921c3c587 | ||
|
|
9d4ff506dc | ||
|
|
065afd0214 | ||
|
|
876008ef01 | ||
|
|
8c61cc88e9 | ||
|
|
24112a9b6f | ||
|
|
9a427f4b9f | ||
|
|
e7c931d997 | ||
|
|
814a961608 | ||
|
|
73743b6236 | ||
|
|
3720851ff7 | ||
|
|
6a83356cf7 | ||
|
|
233c41acc0 | ||
|
|
3b0451da9e | ||
|
|
e217a3146f | ||
|
|
97c42ef1cb | ||
|
|
b12a524de8 | ||
|
|
102cf4c4ad | ||
|
|
ee37fee2c0 | ||
|
|
ef5d9f980e | ||
|
|
8494e0c08a | ||
|
|
2dd1dd1fd0 | ||
|
|
fadbc906e2 | ||
|
|
ba816fc132 | ||
|
|
5ea615da1e | ||
|
|
ceb955b72b | ||
|
|
43823bcb37 | ||
|
|
7984ada306 | ||
|
|
d3e0c8d894 | ||
|
|
cee1be11ab | ||
|
|
230b3207a5 | ||
|
|
62ff35c080 | ||
|
|
e2043c14f2 | ||
|
|
a7f9032347 | ||
|
|
f137868f92 | ||
|
|
175e200d88 | ||
|
|
74f951023b | ||
|
|
3e697338e1 | ||
|
|
bae77017a9 | ||
|
|
32a2834bf3 | ||
|
|
4bffc1c156 | ||
|
|
ac4c5f7d8c | ||
|
|
8f41e55b3c | ||
|
|
ad8aab7b15 | ||
|
|
7e779669ea | ||
|
|
5b01791021 | ||
|
|
14bb068626 | ||
|
|
1c93636538 | ||
|
|
b402a7a32b | ||
|
|
3a7167a65d | ||
|
|
6dd7e9cb38 | ||
|
|
4ffa016045 | ||
|
|
2d6f1ee9b7 | ||
|
|
a1f0615afe | ||
|
|
03ff9c4b27 | ||
|
|
67a48bbec7 | ||
|
|
2b63af82ec | ||
|
|
c5ee7083d8 | ||
|
|
0696f7724d | ||
|
|
b7231e3464 | ||
|
|
214c6c93fd | ||
|
|
7037ae4ba8 | ||
|
|
46d6d6fdee | ||
|
|
ae751bfb91 | ||
|
|
bd0117c52f | ||
|
|
1402695dbe | ||
|
|
72c42afb50 | ||
|
|
2752e0998e | ||
|
|
52114e08ba | ||
|
|
a98721c016 | ||
|
|
c3ab2d09d5 | ||
|
|
9ef7802651 | ||
|
|
a913d33a9e | ||
|
|
49dc7135a7 | ||
|
|
7e77560d70 | ||
|
|
35cb110151 | ||
|
|
4e49c2458d | ||
|
|
755e5fc416 | ||
|
|
5d4fd0269f | ||
|
|
461abf768c | ||
|
|
602bebe498 | ||
|
|
6c31b35f08 | ||
|
|
ccf95ad885 | ||
|
|
fb33921308 | ||
|
|
1121ee0133 | ||
|
|
77af4bd288 | ||
|
|
a1a2119e37 | ||
|
|
afd2806a67 | ||
|
|
3410f0f5bc | ||
|
|
4ed2226206 | ||
|
|
b8d7277d88 | ||
|
|
1becc18354 | ||
|
|
9366d351e0 | ||
|
|
e27f5cd419 | ||
|
|
b7c1116738 | ||
|
|
a6a3d743f7 | ||
|
|
dd3f3e9e5c | ||
|
|
ad2732b249 | ||
|
|
10c04bdda0 | ||
|
|
26d88afeb7 | ||
|
|
376d19563d | ||
|
|
d2895f0f42 | ||
|
|
30310ef2ba | ||
|
|
924a9747f1 | ||
|
|
f8ed48d2d2 | ||
|
|
9cdb2a73e3 | ||
|
|
ad3258b88e | ||
|
|
4461ab080a | ||
|
|
9b07f156b2 | ||
|
|
94c7967800 | ||
|
|
a5b248e663 | ||
|
|
1ec43722e8 | ||
|
|
88c548cc70 | ||
|
|
daafe251da | ||
|
|
147ecbccda | ||
|
|
be5d2d07bc | ||
|
|
adbe8f6c42 | ||
|
|
18aec84be5 | ||
|
|
5f68958aa7 | ||
|
|
4787f644a6 | ||
|
|
524f8df866 | ||
|
|
bb381c1349 | ||
|
|
36c31dac14 | ||
|
|
01b6926054 | ||
|
|
84cfa0a9f7 | ||
|
|
cb83c51632 | ||
|
|
97256ba291 | ||
|
|
d3c596aaa0 | ||
|
|
3d2fa57873 | ||
|
|
c435050018 | ||
|
|
14f761de36 | ||
|
|
626438d8f5 | ||
|
|
e29555a89b | ||
|
|
05da2d7a50 | ||
|
|
1124533557 | ||
|
|
878603c7b0 | ||
|
|
19583cd84a | ||
|
|
9f26d6efdc | ||
|
|
043e620231 | ||
|
|
d3dbdd4ceb | ||
|
|
0859165072 | ||
|
|
ca7ab6105d | ||
|
|
3af2b32783 | ||
|
|
8d5df7e888 | ||
|
|
126ee27505 | ||
|
|
fc2d8452b5 | ||
|
|
1b8c234f30 | ||
|
|
540b607459 | ||
|
|
ee229bd0d7 | ||
|
|
439d39d8fa | ||
|
|
8c379d03a9 | ||
|
|
ff31104b99 | ||
|
|
dfe6063929 | ||
|
|
a4b716f8c7 | ||
|
|
7efc36efef | ||
|
|
1554c9907e | ||
|
|
df46ddcf60 | ||
|
|
6fb19d0287 | ||
|
|
d702f69415 | ||
|
|
eb81e830a1 | ||
|
|
a24b9d7a38 | ||
|
|
efeaa1e895 | ||
|
|
a239eba6ce | ||
|
|
d009582252 | ||
|
|
fe710823c1 | ||
|
|
bfe593ae52 | ||
|
|
f653a22557 | ||
|
|
96e7f22520 | ||
|
|
e6d3d22db7 | ||
|
|
1258dedab3 | ||
|
|
ec15c7e63e | ||
|
|
5037eaf205 | ||
|
|
cb706453aa | ||
|
|
772ebbf929 | ||
|
|
60e1aca3b1 | ||
|
|
741ae4b070 | ||
|
|
64764a78ab | ||
|
|
49476d72fc | ||
|
|
231d099004 | ||
|
|
047b6ff3fe | ||
|
|
10dd50669c | ||
|
|
9f32717d25 | ||
|
|
7e02e6ae96 | ||
|
|
c041c25e0f | ||
|
|
8e7bd16a98 | ||
|
|
f3f1ce5052 | ||
|
|
c83531a3f1 | ||
|
|
746367411c | ||
|
|
21302e4142 | ||
|
|
2c2a20b80d | ||
|
|
aac8c8053d | ||
|
|
de050b3adc | ||
|
|
2f7c054d64 | ||
|
|
515ea96616 | ||
|
|
86da56d35b | ||
|
|
31eaa4181d | ||
|
|
ca13a8accd | ||
|
|
78b1f119dc | ||
|
|
bfb9df48b1 | ||
|
|
acf9aa8b41 | ||
|
|
6e0e7847e4 | ||
|
|
f40de0a017 | ||
|
|
3a7ce0c284 | ||
|
|
dc0fcad843 | ||
|
|
66a18d12dc | ||
|
|
2908b29c0d | ||
|
|
91afa08cdc | ||
|
|
9e701645d5 | ||
|
|
d93b0442d2 | ||
|
|
ce4f9f5f01 | ||
|
|
353d638823 | ||
|
|
995a774140 | ||
|
|
c131b245bc | ||
|
|
42aabaf9b5 | ||
|
|
84cce151b8 | ||
|
|
e3e6316af7 | ||
|
|
96e64c4f17 | ||
|
|
3005917256 | ||
|
|
d34ba8b6f3 | ||
|
|
d35b55f7d3 | ||
|
|
94de760fb5 | ||
|
|
0fa121cdf2 | ||
|
|
3bf6215249 | ||
|
|
2ef045a66d | ||
|
|
2316f38978 | ||
|
|
b65bf12247 | ||
|
|
55291d43a6 | ||
|
|
01bee95833 | ||
|
|
5938fa6ffb | ||
|
|
743fe5a75d | ||
|
|
0c2fdba586 | ||
|
|
a2c5adec3d | ||
|
|
6089c8c7c6 | ||
|
|
f28f725519 | ||
|
|
22d853e0b0 | ||
|
|
0f1d395651 | ||
|
|
3a0bab217d | ||
|
|
f824cb5f15 | ||
|
|
40fd8d6d1a | ||
|
|
e37f73bce0 | ||
|
|
d1cd08972f | ||
|
|
5a13ca6409 | ||
|
|
eb3fd73415 | ||
|
|
1764fcbba2 | ||
|
|
19f3552bfc | ||
|
|
cedce6cf32 | ||
|
|
26cf215150 | ||
|
|
d21557069c | ||
|
|
b2e886fa26 | ||
|
|
28b2547229 | ||
|
|
6945ef5201 | ||
|
|
d75f556074 | ||
|
|
eb66810e59 | ||
|
|
540b39206d | ||
|
|
5baea04c5d | ||
|
|
f5e65748a7 | ||
|
|
de84e09062 | ||
|
|
9beb756ccd | ||
|
|
34c5cfb638 | ||
|
|
c81c88c930 | ||
|
|
0b1122d9af | ||
|
|
2cb39ea7e3 | ||
|
|
6986963e45 | ||
|
|
dc9b0093d9 | ||
|
|
40f9927842 | ||
|
|
ff02f5f3ed | ||
|
|
dc40f6b530 | ||
|
|
22149b94a1 | ||
|
|
d771454aa5 | ||
|
|
372d25667f | ||
|
|
21f6cc00eb | ||
|
|
620a080128 | ||
|
|
6a972aaf3d | ||
|
|
d878d6b20b | ||
|
|
ec075311f4 | ||
|
|
237c9bb62a | ||
|
|
5aa9733bd7 | ||
|
|
a157a003c5 | ||
|
|
e40869d3f8 | ||
|
|
edaecfad4d | ||
|
|
983a98ae15 | ||
|
|
20ad902feb | ||
|
|
05de9c6e41 | ||
|
|
df281cbbaa | ||
|
|
a979d11b8c | ||
|
|
f7ff9c114f | ||
|
|
807dbdd133 | ||
|
|
4aa944237f | ||
|
|
48db55e3da | ||
|
|
bd1491e6e5 | ||
|
|
ac35730e3b | ||
|
|
00023adbc0 | ||
|
|
a70142a4dc | ||
|
|
7b056fe1af | ||
|
|
467be38bd1 | ||
|
|
933054a095 | ||
|
|
f56482157c | ||
|
|
5d0c91d91d | ||
|
|
03136611a1 | ||
|
|
3e7488e4f3 | ||
|
|
3ed7d48d42 | ||
|
|
ef72d89172 | ||
|
|
ad97071862 | ||
|
|
2291892946 | ||
|
|
bf8cfa1421 | ||
|
|
bdd806efff | ||
|
|
c912c4af7b | ||
|
|
fc7f359f28 | ||
|
|
2432112d68 | ||
|
|
3cc52b2da2 | ||
|
|
60192891ed | ||
|
|
21598f6189 | ||
|
|
ae3a96b8d2 | ||
|
|
38385ac936 | ||
|
|
a1987ea193 | ||
|
|
480d167131 | ||
|
|
d873accf3e | ||
|
|
94b448863c | ||
|
|
32acc8555d | ||
|
|
d68ad84155 | ||
|
|
45e82b7f33 | ||
|
|
55ad0fe9f0 | ||
|
|
559815273e | ||
|
|
af76740fd9 | ||
|
|
7dadd50bfe | ||
|
|
dd4cab22c1 | ||
|
|
c4d3e776a1 | ||
|
|
19bb7f5ddb | ||
|
|
d212120f9b | ||
|
|
42da1872e7 | ||
|
|
a080b50c45 | ||
|
|
6d31e9b028 | ||
|
|
b606afa858 | ||
|
|
f9446304b3 | ||
|
|
fbe312d580 | ||
|
|
8d383caaff | ||
|
|
6caf4fa7ce | ||
|
|
606d58b08c | ||
|
|
09258179f0 | ||
|
|
40e986b188 | ||
|
|
37e47041bf | ||
|
|
543438bca0 | ||
|
|
b31290c1fc | ||
|
|
d41111a209 | ||
|
|
828b523382 | ||
|
|
32409ecbee | ||
|
|
3ca2cec63a | ||
|
|
1ed2db0c82 | ||
|
|
2423b74dd0 | ||
|
|
3f781ea298 | ||
|
|
30c5c49aef | ||
|
|
9421e39c34 | ||
|
|
c46805cf4f | ||
|
|
f181343fca | ||
|
|
8a512e4f73 | ||
|
|
06a3750168 | ||
|
|
35c1a5642d | ||
|
|
f29df2ad28 | ||
|
|
75a5714451 | ||
|
|
2882863b5b | ||
|
|
773b6cca14 | ||
|
|
f97370c8f7 | ||
|
|
afad96a375 | ||
|
|
9e5ababfcb | ||
|
|
dc1e0e8db4 | ||
|
|
1e861d1125 | ||
|
|
baa93cb371 | ||
|
|
61dcc8db47 | ||
|
|
15505ffcd8 | ||
|
|
2c557eb015 | ||
|
|
f5a80526ab | ||
|
|
96cef35f09 | ||
|
|
27e1455874 | ||
|
|
278d8428de | ||
|
|
164e667158 | ||
|
|
28b31791e7 | ||
|
|
9515768e62 | ||
|
|
fbbad19cb7 | ||
|
|
eab353ca2e | ||
|
|
cb9ee20763 | ||
|
|
dac12532bc | ||
|
|
1d99734ea0 | ||
|
|
3e764c762a | ||
|
|
7be51168d3 | ||
|
|
530d193734 | ||
|
|
aba5ff75af | ||
|
|
9e34fcb8a8 | ||
|
|
055dd9cd01 | ||
|
|
1437fdc4e3 | ||
|
|
e5c67b16ac | ||
|
|
94987314b8 | ||
|
|
f4f881e839 | ||
|
|
92f5901b95 | ||
|
|
1c0cb601cb | ||
|
|
109f06f8bb | ||
|
|
bf23439792 | ||
|
|
b7a0bc08be | ||
|
|
9d6a26dda9 | ||
|
|
a01ce2c3fc | ||
|
|
ba6298af27 | ||
|
|
3d17e0aa75 | ||
|
|
7e18166160 | ||
|
|
40d8571797 | ||
|
|
25e04e358a | ||
|
|
e473e12c0e | ||
|
|
dfb20df16f | ||
|
|
efcbf439ee | ||
|
|
514f7fedbc | ||
|
|
ee88fedacd | ||
|
|
2933f9c49f | ||
|
|
1cca5d989c | ||
|
|
9981020728 | ||
|
|
56843dcf8b | ||
|
|
e661118192 | ||
|
|
54a7de6cb0 | ||
|
|
13b1e0afbb | ||
|
|
4a48796142 | ||
|
|
9a4fef80b9 | ||
|
|
79dc4b39f1 | ||
|
|
9bc18b774e | ||
|
|
465c36407c | ||
|
|
b99486259e | ||
|
|
ecf5475966 | ||
|
|
90822cc8a3 | ||
|
|
5c46209ddc | ||
|
|
176de87b6b | ||
|
|
7f199c527b | ||
|
|
2432e230c5 | ||
|
|
fc1be0d23d | ||
|
|
d084b9e941 | ||
|
|
6678c0af49 | ||
|
|
37754ecf31 | ||
|
|
709d9633a1 | ||
|
|
7ca57efaad | ||
|
|
342fedca1c | ||
|
|
b1262b0448 | ||
|
|
626aca5181 | ||
|
|
8204322b46 | ||
|
|
70ce86cd53 | ||
|
|
ed3b86cd49 | ||
|
|
b371675494 | ||
|
|
ff06c8e7bd | ||
|
|
8ff41d8fa9 | ||
|
|
65176ac140 | ||
|
|
62a34e90dd | ||
|
|
b52e65278e | ||
|
|
5f5b9ba8cb | ||
|
|
a3221470e7 | ||
|
|
0e115bd92a | ||
|
|
95a50c0ba6 | ||
|
|
e323ccb259 | ||
|
|
3294d0b93b | ||
|
|
55e8694990 | ||
|
|
b3888b391a | ||
|
|
f2907ab40f | ||
|
|
7e7218cbdf | ||
|
|
e41c9cb7f4 | ||
|
|
20f96c88e4 | ||
|
|
66afda1343 | ||
|
|
c5a6212065 | ||
|
|
3e7e355575 | ||
|
|
fb9eb3e4b5 | ||
|
|
a35ac82f24 | ||
|
|
66add6b9e4 | ||
|
|
fe81bde1c9 | ||
|
|
6b223098ab | ||
|
|
788e867a6c | ||
|
|
7ad8d307dc | ||
|
|
b6d4ac5ada | ||
|
|
0a069854e5 | ||
|
|
8770afa211 | ||
|
|
312c193b1a | ||
|
|
3700e2bb93 | ||
|
|
a9be72081c | ||
|
|
f57b57791b | ||
|
|
5cf249afa4 | ||
|
|
3f24627f67 | ||
|
|
806c3fdc00 | ||
|
|
e81ee88cda | ||
|
|
db46f63337 | ||
|
|
395102026d | ||
|
|
b62c078de6 | ||
|
|
47c1c08bed | ||
|
|
a23c4f03e0 | ||
|
|
5a6da60fe8 | ||
|
|
588c47aee7 | ||
|
|
36fd51219a | ||
|
|
bc43a79d97 | ||
|
|
5c22c029d7 | ||
|
|
126d9be9d8 | ||
|
|
09be2822e0 | ||
|
|
a93029f789 | ||
|
|
48cf214f4c | ||
|
|
6834bad7b0 | ||
|
|
855458bab0 | ||
|
|
5be48bf8c8 | ||
|
|
80ac0eea62 | ||
|
|
5995ec468d | ||
|
|
e9a876e8f0 | ||
|
|
90223a5ffd | ||
|
|
8331daae5b | ||
|
|
027280954a | ||
|
|
5138a63d23 | ||
|
|
be95cf5510 | ||
|
|
4082328c2b | ||
|
|
729e840af2 | ||
|
|
e4a38fe277 | ||
|
|
a5cb9c7de6 | ||
|
|
7543109583 | ||
|
|
bff2f10fa4 | ||
|
|
37120bf153 | ||
|
|
b88c85db5e | ||
|
|
c682e3dfc0 | ||
|
|
6c0bbb7778 | ||
|
|
bde8c40d16 | ||
|
|
c4d352ba26 | ||
|
|
cc1c0696ad | ||
|
|
186b784004 | ||
|
|
5441d15654 | ||
|
|
bd61af89ae | ||
|
|
eddd77f97f | ||
|
|
ab0338c318 | ||
|
|
1892bec772 | ||
|
|
bf7070a7da | ||
|
|
314331b956 | ||
|
|
6ff949fdb5 | ||
|
|
21d24b7bea | ||
|
|
8522151949 | ||
|
|
3720099b1d | ||
|
|
073873c33c | ||
|
|
25bf62faa3 | ||
|
|
e54cb9c626 | ||
|
|
208330d73a | ||
|
|
343e3e67ed | ||
|
|
6447003927 | ||
|
|
cbdf925703 | ||
|
|
7440e4a610 | ||
|
|
54a5c3fac0 | ||
|
|
42e60da127 | ||
|
|
325dc9c8a8 | ||
|
|
877427f0db | ||
|
|
1a64e7ba63 | ||
|
|
7dfa59a845 | ||
|
|
62fd19368d | ||
|
|
058518fcba | ||
|
|
6e1d10f052 | ||
|
|
af988fec69 | ||
|
|
dd5979aec8 | ||
|
|
657fbeba79 | ||
|
|
4a0d45ad7d | ||
|
|
f47ec21aa8 | ||
|
|
be40d65982 | ||
|
|
faebacb883 | ||
|
|
df0efc39d5 | ||
|
|
57a299de8f | ||
|
|
be724ec45f | ||
|
|
98c70e662d | ||
|
|
4ed9b84d75 | ||
|
|
b7f05acfd3 | ||
|
|
45ebb37a01 | ||
|
|
f77adea800 | ||
|
|
88b855ed47 | ||
|
|
4fa689873f | ||
|
|
d76b9329fc | ||
|
|
1c43ddd3a9 | ||
|
|
1aedbcef94 | ||
|
|
295280861a | ||
|
|
9f70e20fa0 | ||
|
|
a20e96eb6a | ||
|
|
9b238a3ac6 | ||
|
|
0167597ae0 | ||
|
|
a4f6071c8b | ||
|
|
aa0b0bd249 | ||
|
|
c6185a51c2 | ||
|
|
9c9c717025 | ||
|
|
00342ed569 | ||
|
|
1f0a6b4a79 | ||
|
|
3e767b4723 | ||
|
|
e539b11718 | ||
|
|
2fca8c3850 | ||
|
|
0d3f70a231 | ||
|
|
a3a52aaafe | ||
|
|
a6c4401973 | ||
|
|
2e34ec2a17 | ||
|
|
927afec83c | ||
|
|
8bd1da0552 | ||
|
|
4f571fc3d7 | ||
|
|
c3f8e523cc | ||
|
|
9878f76f65 | ||
|
|
23799562ae | ||
|
|
f441a145b5 | ||
|
|
7189764916 | ||
|
|
70bc707e3a | ||
|
|
90215bde8b | ||
|
|
2b3ae5285b | ||
|
|
9b6d0db5b6 | ||
|
|
723da88ff8 | ||
|
|
5bcf2f4356 | ||
|
|
42680574c1 | ||
|
|
82e723c915 | ||
|
|
ac9560d9d7 | ||
|
|
32f95efa54 | ||
|
|
3da416908d | ||
|
|
d79d2e9ad2 | ||
|
|
30ba36894d | ||
|
|
b747402352 | ||
|
|
0398a9bda3 | ||
|
|
72dff88384 | ||
|
|
0314a9755f | ||
|
|
bc967b15b2 | ||
|
|
8ac686a19f | ||
|
|
aafecaa3a4 | ||
|
|
bb23b08b15 | ||
|
|
476396da53 | ||
|
|
5112971848 | ||
|
|
2d852c38ec | ||
|
|
f163cacddc | ||
|
|
6ecb1cb2b0 | ||
|
|
24fefe0711 | ||
|
|
e5eba69d0d | ||
|
|
bdd2b7e317 | ||
|
|
ad29375975 | ||
|
|
cf73a4ef43 | ||
|
|
60a2621928 | ||
|
|
b4e5d9dbc2 | ||
|
|
650b700415 | ||
|
|
212f742164 | ||
|
|
6f2296eb05 | ||
|
|
722efd74c2 | ||
|
|
5dc9b6defe | ||
|
|
605fbaaa4a | ||
|
|
23b46865c5 | ||
|
|
ac310eaaf5 | ||
|
|
010f59df8a | ||
|
|
44a5dccd61 | ||
|
|
acbbf021a1 | ||
|
|
731fece258 | ||
|
|
8d255d1b89 | ||
|
|
64318c92e7 | ||
|
|
49fc7e48d4 | ||
|
|
ec9fa0baee | ||
|
|
ba91d91fd1 | ||
|
|
0aa1fea9dc | ||
|
|
1551f01f49 | ||
|
|
d46748602e | ||
|
|
9cfad0fe6a | ||
|
|
6d3cff84a4 | ||
|
|
010230645c | ||
|
|
5979290f0c | ||
|
|
e648872257 | ||
|
|
e4910ae31a | ||
|
|
d8ea0c7bcf | ||
|
|
6393d2c188 | ||
|
|
d9f0a163cf | ||
|
|
6534beec14 | ||
|
|
6d050340ee | ||
|
|
0e7f7fa208 | ||
|
|
287be0bd25 | ||
|
|
18cf2ff873 | ||
|
|
b626fb448b | ||
|
|
38f6fb5a7f | ||
|
|
5846df7d02 | ||
|
|
9462d6109c | ||
|
|
f0c93cd06e | ||
|
|
14e0507689 | ||
|
|
393b90f7be | ||
|
|
47ee5c1d84 | ||
|
|
1cb6f2d351 | ||
|
|
bb72b0cdfc | ||
|
|
ab2467b074 | ||
|
|
2d652523bb | ||
|
|
55df50253f | ||
|
|
d009914ff9 | ||
|
|
5e97222206 | ||
|
|
038705483b | ||
|
|
10c9ba5783 | ||
|
|
a1d008688b | ||
|
|
78a043c536 | ||
|
|
acdc840f17 | ||
|
|
63d4b8894b | ||
|
|
23ccbf9642 | ||
|
|
a5793ff768 | ||
|
|
a84e2f72c3 | ||
|
|
0d805a01c1 | ||
|
|
ba90a1c396 | ||
|
|
465927e730 | ||
|
|
74f3c14a62 | ||
|
|
2eb40c7b42 | ||
|
|
457c5f85af | ||
|
|
c6ef3d774a | ||
|
|
12b946157a | ||
|
|
7f1e4c0969 | ||
|
|
e55cd7841f | ||
|
|
8b5b32fecb | ||
|
|
93b126d92b | ||
|
|
5fce7283f1 | ||
|
|
819c9a7506 | ||
|
|
4b3ef50d4b | ||
|
|
bc945c5196 | ||
|
|
57ea3c576e | ||
|
|
450e15f558 | ||
|
|
a66ef977a0 | ||
|
|
96a474adc1 | ||
|
|
1fe22aeef1 | ||
|
|
a97897527e | ||
|
|
86bbb4d885 | ||
|
|
041f8314ab | ||
|
|
dffdeff798 | ||
|
|
d61e399c67 | ||
|
|
601f246bdc | ||
|
|
6f08dc3ada | ||
|
|
07e1b86586 | ||
|
|
ce4883ea39 | ||
|
|
2deda8947e | ||
|
|
adb9532d1b | ||
|
|
a2959342a9 | ||
|
|
f528833232 | ||
|
|
a6b8785341 | ||
|
|
6e7a14fb3e | ||
|
|
708180a037 | ||
|
|
04efa2742c | ||
|
|
0e2c96d544 | ||
|
|
a45c1818a5 | ||
|
|
f04f47d17a | ||
|
|
cabce14a49 | ||
|
|
5f669684c4 | ||
|
|
4d169809bd | ||
|
|
2929d64fa0 | ||
|
|
20311d31f6 | ||
|
|
c13b68ef42 | ||
|
|
8eff623b67 | ||
|
|
f4b9207379 | ||
|
|
90930e19e7 | ||
|
|
8c0dacd6d7 | ||
|
|
c617bea45a | ||
|
|
bac25c9173 | ||
|
|
acfc3f617e | ||
|
|
4c6aa3baf1 | ||
|
|
ed2d72c008 | ||
|
|
3cb82c58a1 | ||
|
|
d87e3cb24d | ||
|
|
8a4c46c40b | ||
|
|
5f3dcdb7e5 | ||
|
|
8964c316b8 | ||
|
|
230f682a27 | ||
|
|
8f25d048df | ||
|
|
90fcf3153c | ||
|
|
069c4cf5c4 | ||
|
|
f10e55ad71 | ||
|
|
a934c7842b | ||
|
|
a2b6bc0493 | ||
|
|
24e418bf7c | ||
|
|
3fc3ef4ea8 | ||
|
|
952d6b9851 | ||
|
|
841c58ca8c | ||
|
|
41164add15 | ||
|
|
f4858d3684 | ||
|
|
be60479122 | ||
|
|
948f160d14 | ||
|
|
768c733f92 | ||
|
|
1a02be7c91 | ||
|
|
ac75f6f7a6 | ||
|
|
b2befb4feb | ||
|
|
3e49399f82 | ||
|
|
eaaaf3effd | ||
|
|
f2cd1be3af | ||
|
|
b4fcf41420 | ||
|
|
5feccae2a0 | ||
|
|
d28318005d | ||
|
|
fcf39d7786 | ||
|
|
5e9fc614d7 | ||
|
|
a860803cc4 | ||
|
|
c40f5953fa | ||
|
|
241282296e | ||
|
|
8a8143167f | ||
|
|
12797293f0 | ||
|
|
af0eb9551a | ||
|
|
8a492450da | ||
|
|
f3cb356b2b | ||
|
|
8ea1b7afba | ||
|
|
911c1bdd0c | ||
|
|
41f3274c7e | ||
|
|
0fc62dda78 | ||
|
|
e482c911c4 | ||
|
|
0e59126c52 | ||
|
|
abbe6437a9 | ||
|
|
f2d67d4128 | ||
|
|
7c9e02996e | ||
|
|
dc560edb7c | ||
|
|
f7bbcee386 | ||
|
|
2182d4b440 | ||
|
|
c43e10c4af | ||
|
|
25037324ab | ||
|
|
b8f9916d13 | ||
|
|
ed8b9cc943 | ||
|
|
efbe7e0a21 | ||
|
|
46dd500d37 | ||
|
|
261c95fb06 | ||
|
|
41a122f722 | ||
|
|
490406e12a | ||
|
|
d12677094d | ||
|
|
3c69792744 | ||
|
|
395e79adbf | ||
|
|
d5e56d8e29 | ||
|
|
e4c4873aa7 | ||
|
|
293da1d4ef | ||
|
|
d1c206a05a | ||
|
|
37b370511f | ||
|
|
734ef5533a | ||
|
|
0eb9b9fdac | ||
|
|
7817890cfe | ||
|
|
23dbedd139 | ||
|
|
2c8e2251fa | ||
|
|
4c27ed9997 | ||
|
|
d2fd1362c0 | ||
|
|
45e57f0d5e | ||
|
|
660facea96 | ||
|
|
9fa2e940d6 | ||
|
|
0ffcfb8f43 | ||
|
|
ad1b3df74e | ||
|
|
0ccf10bbbb | ||
|
|
59c007e801 | ||
|
|
0654bc1049 | ||
|
|
9fabefc847 | ||
|
|
e70ded0be1 | ||
|
|
16806275e0 | ||
|
|
e8214c3aae | ||
|
|
3a8e148301 | ||
|
|
a0b546614f | ||
|
|
5fcea86b94 | ||
|
|
d8c00ed6c0 | ||
|
|
863e68ec88 | ||
|
|
046ee343dc | ||
|
|
2db9e376d5 | ||
|
|
9458128ad6 | ||
|
|
89638e3f56 | ||
|
|
8d492d7d4b | ||
|
|
246c561b64 | ||
|
|
88295f2462 | ||
|
|
d2d4e1cbac | ||
|
|
261e5b59e0 | ||
|
|
fa7ec01329 | ||
|
|
4c4a29f9cf | ||
|
|
9ddcaf4552 | ||
|
|
c806a99fbc | ||
|
|
ad91d360ce | ||
|
|
cf8d7cd71f | ||
|
|
f370799b1d | ||
|
|
f8655b5de4 | ||
|
|
ed3a5778d0 | ||
|
|
19d213059f | ||
|
|
276a802ab2 | ||
|
|
e756ded89f | ||
|
|
b551f0fe2d | ||
|
|
f6e8bdb0fd | ||
|
|
9029ea8085 | ||
|
|
d61ade9fe9 | ||
|
|
aa1fe549c7 | ||
|
|
e3701bbcb4 | ||
|
|
fb7fc4bf0c | ||
|
|
f8c59a1730 | ||
|
|
ca0c64094c | ||
|
|
5158df21c7 | ||
|
|
39b2e8ec05 | ||
|
|
9d6c9ac04e | ||
|
|
8e50c9baf3 | ||
|
|
936165fba8 | ||
|
|
377e874ef2 | ||
|
|
4d98558019 | ||
|
|
ef70fd2d2a | ||
|
|
3bd6777070 | ||
|
|
b02e9ba52b | ||
|
|
3a053d3104 | ||
|
|
4f6de0c68d | ||
|
|
d084c426fd | ||
|
|
b4802e9abf | ||
|
|
7f6a43c2fa | ||
|
|
0b784af4ca | ||
|
|
fa6e70a13a | ||
|
|
9b6c7966de | ||
|
|
f04f295b21 | ||
|
|
8ada23c9be | ||
|
|
82bac7b18f | ||
|
|
362429451d | ||
|
|
dc50ca157d | ||
|
|
ff2e775b5e | ||
|
|
6dea4aec89 | ||
|
|
584d48c5ab | ||
|
|
25df43b0be | ||
|
|
1af1fcd148 | ||
|
|
516f9aad45 | ||
|
|
79a420de0f | ||
|
|
ac213b6664 | ||
|
|
ff2d74029a | ||
|
|
31ac1d3f2d | ||
|
|
2c32382ca6 | ||
|
|
0d94c20deb | ||
|
|
9904df1611 | ||
|
|
2d945d4fb2 | ||
|
|
c1f9a22bf3 | ||
|
|
22e2e2339e | ||
|
|
b6435bbfc9 | ||
|
|
63387cb958 | ||
|
|
a8d104ec57 | ||
|
|
d0abcfe355 | ||
|
|
8b1d0063ff | ||
|
|
8cd7e48e85 | ||
|
|
aee005b624 | ||
|
|
1d050e8784 | ||
|
|
0c37b2ce5c | ||
|
|
73f401f106 | ||
|
|
d2a0c540ba | ||
|
|
4458d5b8f7 | ||
|
|
a59d6dfb11 | ||
|
|
21e2cf10c2 | ||
|
|
c94ca00daa | ||
|
|
0ec2160eff | ||
|
|
6c75df70e0 | ||
|
|
0211535f73 | ||
|
|
2d4027c214 | ||
|
|
5b3fb315d7 | ||
|
|
2432bb11c7 | ||
|
|
10377b527f | ||
|
|
24650edd62 | ||
|
|
d29d1428ed | ||
|
|
87fb568995 | ||
|
|
355209769f | ||
|
|
91d526b15f | ||
|
|
72cc5cc5ea | ||
|
|
22c86cf3b5 | ||
|
|
d4552fa075 | ||
|
|
a0573c439b | ||
|
|
8dafd918ed | ||
|
|
050cdd0a85 | ||
|
|
55f09fe21a | ||
|
|
f069b41df6 | ||
|
|
f81369d643 | ||
|
|
f1d7d34f1a | ||
|
|
ce1f7a4274 | ||
|
|
6ce1d31ceb | ||
|
|
ecb467f2b7 | ||
|
|
4ffaadd481 | ||
|
|
4c933669b9 | ||
|
|
a7001beced | ||
|
|
b864c338dd | ||
|
|
6c295611cc | ||
|
|
61d37c4c19 | ||
|
|
296579fa87 | ||
|
|
995f39dfdf | ||
|
|
c7cf8d5255 | ||
|
|
e1079f954e | ||
|
|
d2524adcd2 | ||
|
|
e778942711 | ||
|
|
04136cd9c0 | ||
|
|
247108f347 | ||
|
|
c833c3591f | ||
|
|
ccbd962e0b | ||
|
|
966d2afe69 | ||
|
|
1a8075e2f1 | ||
|
|
b47ede7772 | ||
|
|
ebbb8b396c | ||
|
|
a2cace6c0f | ||
|
|
c0593707f2 | ||
|
|
8b98fdcba1 | ||
|
|
a05c5821b3 | ||
|
|
140fbc1524 | ||
|
|
6bb093e6d3 | ||
|
|
609ec19e06 | ||
|
|
acb3030d56 | ||
|
|
0fc5b2e997 | ||
|
|
41a7d6738b | ||
|
|
11461221ba | ||
|
|
ce25bd10ff | ||
|
|
9c5bac5741 | ||
|
|
9a42536205 | ||
|
|
74e0ab071c | ||
|
|
0b136f3aae | ||
|
|
01dae831a4 | ||
|
|
e2062558b7 | ||
|
|
259405d707 | ||
|
|
ef7502be34 | ||
|
|
13e26c5b3f | ||
|
|
5fec715e3f | ||
|
|
97443c0682 | ||
|
|
53c0b920e2 | ||
|
|
79b2bc8b93 | ||
|
|
360d9d5202 | ||
|
|
bf7af98739 | ||
|
|
b574237dfb | ||
|
|
afe597c811 | ||
|
|
48219f54fc | ||
|
|
d171409301 | ||
|
|
e508a4cd43 | ||
|
|
a5da35b7ae | ||
|
|
2016c97a12 | ||
|
|
9595f52a9c | ||
|
|
9ee17445a5 | ||
|
|
cd97e2c861 | ||
|
|
db6f034cb5 | ||
|
|
46b478ec17 | ||
|
|
de57a39df6 | ||
|
|
8eb45e2814 | ||
|
|
5bb0887d8b | ||
|
|
b5f7f89c27 | ||
|
|
fa7d1d3f80 | ||
|
|
2eef2f801f | ||
|
|
6ebf9f59a0 | ||
|
|
eddb47c9c4 | ||
|
|
8d38b818c0 | ||
|
|
af462ab0f9 | ||
|
|
07753a6253 | ||
|
|
54b12cf560 | ||
|
|
f97f5da837 | ||
|
|
19e315dc1a | ||
|
|
96d01d6379 | ||
|
|
ee156f1183 | ||
|
|
f83e184fcd | ||
|
|
a2ead45c83 | ||
|
|
b295f1e957 | ||
|
|
cbd4fd3820 | ||
|
|
b27fa2a555 | ||
|
|
2afd9b474c | ||
|
|
680ac80526 | ||
|
|
4b08a33307 | ||
|
|
04db52145d | ||
|
|
ae996e8847 | ||
|
|
06cb568fbd | ||
|
|
39a1aa360d | ||
|
|
51ed4dece2 | ||
|
|
1620b0be62 | ||
|
|
4c7c8a19c5 | ||
|
|
93f825e970 | ||
|
|
310035be1b | ||
|
|
4ec90e5575 | ||
|
|
5ba5aee160 | ||
|
|
aecca66972 | ||
|
|
a872664789 | ||
|
|
0386e7dd4f | ||
|
|
573ab077ac | ||
|
|
496405d922 | ||
|
|
7b639f2718 | ||
|
|
7dcc1496ec | ||
|
|
0dc7d71d1b | ||
|
|
591b3a121f | ||
|
|
a81dae2ad0 | ||
|
|
d370ee2d99 | ||
|
|
a6b1af6a16 | ||
|
|
2d21627aff | ||
|
|
0fb3f98136 | ||
|
|
3889d7a5be | ||
|
|
9d3c997743 | ||
|
|
8c86f9fcea | ||
|
|
dd67710b12 | ||
|
|
1c74a019ab | ||
|
|
6d376731e3 | ||
|
|
11a46f0f58 | ||
|
|
5157fd9ecd | ||
|
|
4226827b5d | ||
|
|
cb3b362bad | ||
|
|
4dcb08745b | ||
|
|
28c57813db | ||
|
|
49868362cd | ||
|
|
c2b965c24b | ||
|
|
6c3e16db20 | ||
|
|
b880d81104 | ||
|
|
ef8db52ebe | ||
|
|
185a88e655 | ||
|
|
3eef1a1c59 | ||
|
|
78451b9721 | ||
|
|
26973681ec | ||
|
|
f48b67f872 | ||
|
|
8d5ccb5ba8 | ||
|
|
619751a8aa | ||
|
|
be9c55acae | ||
|
|
ffd37755a3 | ||
|
|
9991b8f1e2 | ||
|
|
13eb8152e0 | ||
|
|
7bf6db7817 | ||
|
|
a1eb79fcb0 | ||
|
|
3f5cdc533e | ||
|
|
697ea995cb | ||
|
|
a2002b8e9c | ||
|
|
c1d8637fec | ||
|
|
b6ea29ffc9 | ||
|
|
6aa0c573fb | ||
|
|
fcc575c508 | ||
|
|
62d6ce08a0 | ||
|
|
b50127b0d3 | ||
|
|
669a58cc0e | ||
|
|
bf4b5dad5a | ||
|
|
39972a9bd7 | ||
|
|
44f519c1d6 | ||
|
|
dd6c5bbf12 | ||
|
|
20d4db2608 | ||
|
|
3151e86665 | ||
|
|
96a0d483f5 | ||
|
|
3faefdbc85 | ||
|
|
12347d5c4a | ||
|
|
4dbaadf9cc | ||
|
|
2a1c165a54 | ||
|
|
939f931809 | ||
|
|
4fd09bf1f8 | ||
|
|
3231db3c3f | ||
|
|
c07ea1bfa7 | ||
|
|
79db638bf4 | ||
|
|
794dab2894 | ||
|
|
97b303aea6 | ||
|
|
a259b65085 | ||
|
|
5ea014cc37 | ||
|
|
3210dbb6d8 | ||
|
|
64cbb2c7d2 | ||
|
|
3b35dc50c5 | ||
|
|
a768d2f7a7 | ||
|
|
0a0157a1ef | ||
|
|
680fb4122c | ||
|
|
3502324389 | ||
|
|
97e52e53bf | ||
|
|
dfd68ca8a3 | ||
|
|
fc6bdb56df | ||
|
|
129f1ccd8d | ||
|
|
f6e92c411e | ||
|
|
db98884ae4 | ||
|
|
a7a1e5c480 | ||
|
|
156ac3be6d | ||
|
|
ccc0038d4e | ||
|
|
3684f4727c | ||
|
|
b32dd949d7 | ||
|
|
015e50cdb8 | ||
|
|
efd294d53b | ||
|
|
f9eb4bf574 | ||
|
|
b49912bf71 | ||
|
|
f5f11de58e | ||
|
|
a8ea40b2e1 | ||
|
|
308bab8a3c | ||
|
|
ef8c4cef8a | ||
|
|
63198a03ab | ||
|
|
ed808abd22 | ||
|
|
9fe23442f5 | ||
|
|
0e2e86e7d3 | ||
|
|
f00e051e75 | ||
|
|
ea0e3fd248 | ||
|
|
e7adf08854 | ||
|
|
7646d8be07 | ||
|
|
2ac85a1d1c | ||
|
|
cb71dc4202 | ||
|
|
6637542e7c | ||
|
|
978e02350c | ||
|
|
971ce09811 | ||
|
|
04826074f4 | ||
|
|
bcd4baff3d | ||
|
|
3bcf7b22be | ||
|
|
ee8c54bdd3 | ||
|
|
1af8699fc0 | ||
|
|
b4d0af6eb2 | ||
|
|
555d997e34 | ||
|
|
1b9124422a | ||
|
|
81f02209ea | ||
|
|
8f23874628 | ||
|
|
124d456c60 | ||
|
|
75da044bbe | ||
|
|
5bc1fc71ef | ||
|
|
0b5ce95093 | ||
|
|
77971a10d1 | ||
|
|
28a56ff7bf | ||
|
|
d7d28bcf58 | ||
|
|
682e1549f8 | ||
|
|
d7d2b21935 | ||
|
|
1b7d2da6cb | ||
|
|
19dfbaacce | ||
|
|
31578521cf | ||
|
|
8d5e82fa5e | ||
|
|
bd25ae77fc | ||
|
|
9aec8be1c0 | ||
|
|
90ac727250 | ||
|
|
5bc9840825 | ||
|
|
0525114036 | ||
|
|
fb691f6ade | ||
|
|
92f1048911 | ||
|
|
6481b90daf | ||
|
|
4d753398c1 | ||
|
|
f01bebedc9 | ||
|
|
48dd93b94b | ||
|
|
ac231374f6 | ||
|
|
87fc4e1281 | ||
|
|
8fd2cb39c1 | ||
|
|
24a01aefe2 | ||
|
|
06fb9c0a6b | ||
|
|
bc0942180e | ||
|
|
f358563c27 | ||
|
|
dcaebeea83 | ||
|
|
ac13af73c5 | ||
|
|
ba529d2721 | ||
|
|
f23a7b4842 | ||
|
|
5718631889 | ||
|
|
da3d71d21e | ||
|
|
b533546236 | ||
|
|
1c148f407c | ||
|
|
9403efa9a1 | ||
|
|
6a06fc7995 | ||
|
|
77733ce205 | ||
|
|
585b6ccd3e | ||
|
|
ac24c69858 | ||
|
|
9350c43e5b | ||
|
|
0fae11d54c | ||
|
|
1ed3999639 | ||
|
|
7d30771f05 | ||
|
|
08f1d44d90 | ||
|
|
969860c344 | ||
|
|
ed905c9d64 | ||
|
|
c5518b64b7 | ||
|
|
a7b2b631c5 | ||
|
|
dcfc1119eb | ||
|
|
88add55ebc | ||
|
|
ad41a58904 | ||
|
|
49ce312ab2 | ||
|
|
223d69206c | ||
|
|
d68ada1026 | ||
|
|
e0a23f6b63 | ||
|
|
bd147ea72e | ||
|
|
4494aed1cf | ||
|
|
788eaad61c | ||
|
|
0cfd6bae0e | ||
|
|
82c435b916 | ||
|
|
bc5b9708c7 | ||
|
|
7e87e6f832 | ||
|
|
e5a7a32439 | ||
|
|
e9214d84b7 | ||
|
|
da7a61a8b6 | ||
|
|
458e858b24 | ||
|
|
ec84e72b4c | ||
|
|
64a8c3b005 | ||
|
|
0b5cf2e6c8 | ||
|
|
7ed4e1c284 | ||
|
|
9dd7616f7d | ||
|
|
ab29caff7b | ||
|
|
7633e3d48e | ||
|
|
411fdf3114 | ||
|
|
5c52917459 | ||
|
|
51753ad82a | ||
|
|
7e00634f3d | ||
|
|
daf41804d4 | ||
|
|
43d087f886 | ||
|
|
503a6e520d | ||
|
|
52610a7410 | ||
|
|
c7edb71fed | ||
|
|
83db37ed31 | ||
|
|
0d1c8ae01e | ||
|
|
92f71e100f | ||
|
|
659573b864 | ||
|
|
e1c798561b | ||
|
|
0c52b56e02 | ||
|
|
f9731d9cfc | ||
|
|
7547371ba0 | ||
|
|
84e1d45d2a | ||
|
|
364c9cda27 | ||
|
|
af944c29a8 | ||
|
|
45577f1585 | ||
|
|
1648c67467 | ||
|
|
882793e794 | ||
|
|
4a4a7d79c2 | ||
|
|
a955eb80da | ||
|
|
cd64a1ee18 | ||
|
|
9894d4256c | ||
|
|
3b5f1dabd6 | ||
|
|
750fa2e647 | ||
|
|
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 | ||
|
|
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 | ||
|
|
5d474150da | ||
|
|
d3941752f1 | ||
|
|
56b305b1de | ||
|
|
bde472d649 | ||
|
|
c1548b0f54 | ||
|
|
6f04738629 | ||
|
|
f79af7b045 | ||
|
|
527f502083 | ||
|
|
d61e2c6f2c | ||
|
|
ea31d2f446 | ||
|
|
62803a1817 | ||
|
|
a67464b4a0 | ||
|
|
00e7482968 | ||
|
|
b9cef158d8 | ||
|
|
5ec6141369 | ||
|
|
55ac1e01f2 | ||
|
|
65b58c3668 | ||
|
|
2cb4e5e8dc | ||
|
|
72cea245f1 | ||
|
|
08ca86c68a | ||
|
|
925c9c1e7b | ||
|
|
6212ea0304 | ||
|
|
f295592134 | ||
|
|
69b0973e6d | ||
|
|
422d318dac | ||
|
|
c55aa6ee88 | ||
|
|
090b175152 | ||
|
|
11e9b097a2 | ||
|
|
2adfc1d32b | ||
|
|
99fa5d89e7 | ||
|
|
ca8cbf8ccf | ||
|
|
6722d2d266 | ||
|
|
508cbeaa1b | ||
|
|
e040865905 | ||
|
|
a7878dd2c6 | ||
|
|
02980834ad | ||
|
|
2a8c8871c4 | ||
|
|
893be24c1d | ||
|
|
9029f59410 | ||
|
|
4b5e8d33a6 | ||
|
|
09196c045f | ||
|
|
7868ebec1e | ||
|
|
80a9182f05 | ||
|
|
d20b3d854f | ||
|
|
f1356228a3 | ||
|
|
a4adc51e50 | ||
|
|
864543e4f9 | ||
|
|
33a549202b | ||
|
|
c4a0219b18 |
18
.github/actions/build-electron/action.yml
vendored
18
.github/actions/build-electron/action.yml
vendored
@@ -66,12 +66,20 @@ runs:
|
|||||||
if: ${{ inputs.os == 'linux' }}
|
if: ${{ inputs.os == 'linux' }}
|
||||||
shell: ${{ inputs.shell }}
|
shell: ${{ inputs.shell }}
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update && sudo apt-get install rpm flatpak-builder elfutils
|
sudo apt-get update && sudo apt-get install rpm flatpak-builder elfutils libfuse2
|
||||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||||
FLATPAK_ARCH=$(if [[ ${{ inputs.arch }} = 'arm64' ]]; then echo 'aarch64'; else echo 'x86_64'; fi)
|
FLATPAK_ARCH=$(if [[ ${{ inputs.arch }} = 'arm64' ]]; then echo 'aarch64'; else echo 'x86_64'; fi)
|
||||||
FLATPAK_VERSION='24.08'
|
FLATPAK_VERSION='24.08'
|
||||||
flatpak install --user --no-deps --arch $FLATPAK_ARCH --assumeyes runtime/org.freedesktop.Platform/$FLATPAK_ARCH/$FLATPAK_VERSION runtime/org.freedesktop.Sdk/$FLATPAK_ARCH/$FLATPAK_VERSION org.electronjs.Electron2.BaseApp/$FLATPAK_ARCH/$FLATPAK_VERSION
|
flatpak install --user --no-deps --arch $FLATPAK_ARCH --assumeyes runtime/org.freedesktop.Platform/$FLATPAK_ARCH/$FLATPAK_VERSION runtime/org.freedesktop.Sdk/$FLATPAK_ARCH/$FLATPAK_VERSION org.electronjs.Electron2.BaseApp/$FLATPAK_ARCH/$FLATPAK_VERSION
|
||||||
|
|
||||||
|
- name: Install appimagetool
|
||||||
|
if: ${{ inputs.os == 'linux' }}
|
||||||
|
shell: ${{ inputs.shell }}
|
||||||
|
run: |
|
||||||
|
APPIMAGETOOL_ARCH=$(if [[ ${{ inputs.arch }} = 'arm64' ]]; then echo 'aarch64'; else echo 'x86_64'; fi)
|
||||||
|
wget -q "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${APPIMAGETOOL_ARCH}.AppImage" -O /usr/local/bin/appimagetool
|
||||||
|
chmod +x /usr/local/bin/appimagetool
|
||||||
|
|
||||||
- name: Update build info
|
- name: Update build info
|
||||||
shell: ${{ inputs.shell }}
|
shell: ${{ inputs.shell }}
|
||||||
run: pnpm run chore:update-build-info
|
run: pnpm run chore:update-build-info
|
||||||
@@ -90,6 +98,14 @@ runs:
|
|||||||
TARGET_ARCH: ${{ inputs.arch }}
|
TARGET_ARCH: ${{ inputs.arch }}
|
||||||
run: pnpm run --filter desktop electron-forge:make --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
|
run: pnpm run --filter desktop electron-forge:make --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
|
||||||
|
|
||||||
|
- name: Build AppImage
|
||||||
|
if: ${{ inputs.os == 'linux' }}
|
||||||
|
shell: ${{ inputs.shell }}
|
||||||
|
env:
|
||||||
|
TRILIUM_ARTIFACT_NAME_HINT: TriliumNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
|
||||||
|
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||||
|
run: bash apps/desktop/scripts/build-appimage.sh ${{ inputs.arch }}
|
||||||
|
|
||||||
# Add DMG signing step
|
# Add DMG signing step
|
||||||
- name: Sign DMG
|
- name: Sign DMG
|
||||||
if: inputs.os == 'macos'
|
if: inputs.os == 'macos'
|
||||||
|
|||||||
2
.github/actions/build-server/action.yml
vendored
2
.github/actions/build-server/action.yml
vendored
@@ -8,7 +8,7 @@ inputs:
|
|||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ runs:
|
|||||||
# Post deployment URL as PR comment
|
# Post deployment URL as PR comment
|
||||||
- name: Comment PR with Preview URL
|
- name: Comment PR with Preview URL
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v9
|
||||||
env:
|
env:
|
||||||
COMMENT_BODY: ${{ inputs.comment_body }}
|
COMMENT_BODY: ${{ inputs.comment_body }}
|
||||||
PRODUCTION_URL: ${{ inputs.production_url }}
|
PRODUCTION_URL: ${{ inputs.production_url }}
|
||||||
|
|||||||
53
.github/copilot-instructions.md
vendored
53
.github/copilot-instructions.md
vendored
@@ -1,5 +1,7 @@
|
|||||||
# Trilium Notes - AI Coding Agent Instructions
|
# Trilium Notes - AI Coding Agent Instructions
|
||||||
|
|
||||||
|
> **Note**: When updating this file, also update `CLAUDE.md` in the repository root to keep both AI coding assistants in sync.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. Built as a TypeScript monorepo using pnpm, it implements a three-layer caching architecture (Becca/Froca/Shaca) with a widget-based UI system and supports extensive user scripting capabilities.
|
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. Built as a TypeScript monorepo using pnpm, it implements a three-layer caching architecture (Becca/Froca/Shaca) with a widget-based UI system and supports extensive user scripting capabilities.
|
||||||
@@ -115,6 +117,15 @@ class MyNoteWidget extends NoteContextAwareWidget {
|
|||||||
|
|
||||||
**Important**: Widgets use jQuery (`this.$widget`) for DOM manipulation. Don't mix React patterns here.
|
**Important**: Widgets use jQuery (`this.$widget`) for DOM manipulation. Don't mix React patterns here.
|
||||||
|
|
||||||
|
### Reusable Preact Components
|
||||||
|
Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating custom implementations:
|
||||||
|
- `NoItems` - Empty state placeholder with icon and message (use for "no results", "too many items", error states)
|
||||||
|
- `ActionButton` - Consistent button styling with icon support
|
||||||
|
- `FormTextBox` - Text input with validation and controlled input handling
|
||||||
|
- `Slider` - Range slider with label
|
||||||
|
- `Checkbox`, `RadioButton` - Form controls
|
||||||
|
- `CollapsibleSection` - Expandable content sections
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
### Running & Testing
|
### Running & Testing
|
||||||
@@ -143,7 +154,7 @@ pnpm desktop:build # Build desktop application
|
|||||||
### Test Organization
|
### Test Organization
|
||||||
- **Server tests** (`apps/server/spec/`): Must run sequentially (shared database state)
|
- **Server tests** (`apps/server/spec/`): Must run sequentially (shared database state)
|
||||||
- **Client tests** (`apps/client/src/`): Can run in parallel
|
- **Client tests** (`apps/client/src/`): Can run in parallel
|
||||||
- **E2E tests** (`apps/server-e2e/`): Use Playwright for integration testing
|
- **E2E tests** (`packages/trilium-e2e/`): Shared Playwright tests, run via `pnpm --filter server e2e` or `pnpm --filter client-standalone e2e`
|
||||||
- **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests
|
- **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests
|
||||||
|
|
||||||
**Pattern**: When adding new API endpoints, add tests in `spec/etapi/` following existing patterns (see `search.spec.ts`).
|
**Pattern**: When adding new API endpoints, add tests in `spec/etapi/` following existing patterns (see `search.spec.ts`).
|
||||||
@@ -186,6 +197,14 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main
|
|||||||
|
|
||||||
**Auth note**: ETAPI uses basic auth with tokens. Internal API endpoints trust the frontend.
|
**Auth note**: ETAPI uses basic auth with tokens. Internal API endpoints trust the frontend.
|
||||||
|
|
||||||
|
### Adding New LLM Tools
|
||||||
|
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
|
||||||
|
|
||||||
|
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `hierarchy_tools.ts`) or create a new module
|
||||||
|
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations or `needsContext: true` for tools that need the current note context
|
||||||
|
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
|
||||||
|
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
|
||||||
|
|
||||||
### Database Migrations
|
### Database Migrations
|
||||||
- Add scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
- Add scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
||||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||||
@@ -213,6 +232,12 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main
|
|||||||
|
|
||||||
10. **Attribute inheritance can be complex** - When checking for labels/relations, use `note.getOwnedAttribute()` for direct attributes or `note.getAttribute()` for inherited ones. Don't assume attributes are directly on the note.
|
10. **Attribute inheritance can be complex** - When checking for labels/relations, use `note.getOwnedAttribute()` for direct attributes or `note.getAttribute()` for inherited ones. Don't assume attributes are directly on the note.
|
||||||
|
|
||||||
|
## MCP Server
|
||||||
|
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
|
||||||
|
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
|
||||||
|
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
|
||||||
|
- Use it to interact with actual note data when developing or debugging note-related features
|
||||||
|
|
||||||
## TypeScript Configuration
|
## TypeScript Configuration
|
||||||
|
|
||||||
- **Project references**: Monorepo uses TypeScript project references (`tsconfig.json`)
|
- **Project references**: Monorepo uses TypeScript project references (`tsconfig.json`)
|
||||||
@@ -275,6 +300,12 @@ View types are configured via `#viewType` label (e.g., `#viewType=table`). Each
|
|||||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||||
- See `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid` for examples
|
- See `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid` for examples
|
||||||
|
|
||||||
|
### Updating PDF.js
|
||||||
|
1. Update `pdfjs-dist` version in `packages/pdfjs-viewer/package.json`
|
||||||
|
2. Run `npx tsx scripts/update-viewer.ts` from that directory
|
||||||
|
3. Run `pnpm build` to verify success
|
||||||
|
4. Commit all changes including updated viewer files
|
||||||
|
|
||||||
### Database Migrations
|
### Database Migrations
|
||||||
- Add migration scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
- Add migration scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
||||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||||
@@ -299,9 +330,29 @@ Trilium provides powerful user scripting capabilities:
|
|||||||
- Translation files in `apps/client/src/translations/`
|
- Translation files in `apps/client/src/translations/`
|
||||||
- Use translation system via `t()` function
|
- Use translation system via `t()` function
|
||||||
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
|
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
|
||||||
|
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
|
||||||
|
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
|
||||||
|
|
||||||
|
#### Client vs Server Translation Usage
|
||||||
|
- **Client-side**: `import { t } from "../services/i18n"` with keys in `apps/client/src/translations/en/translation.json`
|
||||||
|
- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json`
|
||||||
|
- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped
|
||||||
|
|
||||||
|
### Storing User Preferences
|
||||||
|
- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices
|
||||||
|
- To add a new user preference:
|
||||||
|
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
|
||||||
|
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
|
||||||
|
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
|
||||||
|
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
|
||||||
|
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
|
||||||
|
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
|
||||||
|
|
||||||
## Testing Conventions
|
## Testing Conventions
|
||||||
|
|
||||||
|
- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests
|
||||||
|
- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ETAPI test pattern
|
// ETAPI test pattern
|
||||||
describe("etapi/feature", () => {
|
describe("etapi/feature", () => {
|
||||||
|
|||||||
44
.github/workflows/claude-code-review.yml
vendored
Normal file
44
.github/workflows/claude-code-review.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
|
# Optional: Only run on specific file changes
|
||||||
|
# paths:
|
||||||
|
# - "src/**/*.ts"
|
||||||
|
# - "src/**/*.tsx"
|
||||||
|
# - "src/**/*.js"
|
||||||
|
# - "src/**/*.jsx"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
# Optional: Filter by PR author
|
||||||
|
# if: |
|
||||||
|
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||||
|
# github.event.pull_request.user.login == 'new-developer' ||
|
||||||
|
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
id: claude-review
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
|
plugins: 'code-review@claude-code-plugins'
|
||||||
|
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
|
||||||
50
.github/workflows/claude.yml
vendored
Normal file
50
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||||
|
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||||
|
|
||||||
|
# Optional: Add claude_args to customize behavior and configuration
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||||
|
|
||||||
70
.github/workflows/deploy-app.yml
vendored
Normal file
70
.github/workflows/deploy-app.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Deploy Standalone App
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Trigger on push to main branch
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- standalone
|
||||||
|
# Only run when app files change
|
||||||
|
paths:
|
||||||
|
- 'apps/client/**'
|
||||||
|
- 'apps/client-standalone/**'
|
||||||
|
- 'packages/trilium-core/**'
|
||||||
|
- '.github/workflows/deploy-app.yml'
|
||||||
|
|
||||||
|
# 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/**'
|
||||||
|
- '.github/workflows/deploy-app.yml'
|
||||||
|
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: Update build info
|
||||||
|
run: pnpm run chore:update-build-info
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
2
.github/workflows/deploy-docs.yml
vendored
2
.github/workflows/deploy-docs.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v5
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|||||||
25
.github/workflows/dev.yml
vendored
25
.github/workflows/dev.yml
vendored
@@ -1,9 +1,15 @@
|
|||||||
name: Dev
|
name: Dev
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
|
- standalone
|
||||||
|
- "release/*"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
|
- standalone
|
||||||
|
- "release/*"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -26,7 +32,7 @@ jobs:
|
|||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -59,13 +65,20 @@ jobs:
|
|||||||
path: apps/server/test-output/vitest/html/
|
path: apps/server/test-output/vitest/html/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Run the client-standalone tests
|
||||||
|
# Runs the same trilium-core spec set as the server suite, but in
|
||||||
|
# happy-dom + sql.js WASM via BrowserSqlProvider (see
|
||||||
|
# apps/client-standalone/src/test_setup.ts). Catches differences
|
||||||
|
# between the Node-side and browser-side runtimes.
|
||||||
|
run: pnpm run --filter=client-standalone test
|
||||||
|
|
||||||
- name: Run CKEditor e2e tests
|
- name: Run CKEditor e2e tests
|
||||||
run: |
|
run: |
|
||||||
pnpm run --filter=ckeditor5-mermaid test
|
pnpm run --filter=ckeditor5-mermaid test
|
||||||
pnpm run --filter=ckeditor5-math test
|
pnpm run --filter=ckeditor5-math test
|
||||||
|
|
||||||
- name: Run the rest of the tests
|
- name: Run the rest of the tests
|
||||||
run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
|
run: pnpm run --filter=\!client --filter=\!client-standalone --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
|
||||||
|
|
||||||
build_docker:
|
build_docker:
|
||||||
name: Build Docker image
|
name: Build Docker image
|
||||||
@@ -74,7 +87,7 @@ jobs:
|
|||||||
- test_dev
|
- test_dev
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Update build info
|
- name: Update build info
|
||||||
@@ -109,7 +122,7 @@ jobs:
|
|||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/i18n.yml
vendored
2
.github/workflows/i18n.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
7
.github/workflows/main-docker.yml
vendored
7
.github/workflows/main-docker.yml
vendored
@@ -2,6 +2,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "main"
|
- "main"
|
||||||
|
- "standalone"
|
||||||
- "feature/update**"
|
- "feature/update**"
|
||||||
- "feature/server_esm**"
|
- "feature/server_esm**"
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
@@ -42,7 +43,7 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -82,7 +83,7 @@ jobs:
|
|||||||
require-healthy: true
|
require-healthy: true
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm --filter=server-e2e e2e
|
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm --filter=server e2e
|
||||||
|
|
||||||
- name: Upload Playwright trace
|
- name: Upload Playwright trace
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -142,7 +143,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
57
.github/workflows/mobile.yml
vendored
Normal file
57
.github/workflows/mobile.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: Mobile
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_android:
|
||||||
|
name: Build Android APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout the repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- name: Set up Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v5
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Update build info
|
||||||
|
run: pnpm run chore:update-build-info
|
||||||
|
|
||||||
|
- name: Build client-standalone (webDir for Capacitor)
|
||||||
|
run: pnpm --filter @triliumnext/mobile build
|
||||||
|
|
||||||
|
- name: Sync Capacitor Android project
|
||||||
|
run: pnpm --filter @triliumnext/mobile exec cap sync android
|
||||||
|
|
||||||
|
- name: Assemble debug APK
|
||||||
|
working-directory: apps/mobile/android
|
||||||
|
run: ./gradlew assembleDebug --no-daemon
|
||||||
|
|
||||||
|
- name: Upload APK
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: trilium-mobile-debug-apk
|
||||||
|
path: apps/mobile/android/app/build/outputs/apk/debug/*.apk
|
||||||
|
retention-days: 14
|
||||||
12
.github/workflows/nightly.yml
vendored
12
.github/workflows/nightly.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
forge_platform: linux
|
forge_platform: linux
|
||||||
- name: windows
|
- name: windows
|
||||||
image: win-signing
|
image: windows-latest
|
||||||
shell: cmd
|
shell: cmd
|
||||||
forge_platform: win32
|
forge_platform: win32
|
||||||
# Exclude ARM64 Linux from default matrix to use native runner
|
# Exclude ARM64 Linux from default matrix to use native runner
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os.image }}
|
runs-on: ${{ matrix.os.image }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -69,6 +69,8 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
npm_config_package_import_method: copy
|
||||||
- name: Update nightly version
|
- name: Update nightly version
|
||||||
run: pnpm run chore:ci-update-nightly-version
|
run: pnpm run chore:ci-update-nightly-version
|
||||||
- name: Run the build
|
- name: Run the build
|
||||||
@@ -86,12 +88,10 @@ jobs:
|
|||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
|
||||||
WINDOWS_SIGN_ERROR_LOG: ${{ vars.WINDOWS_SIGN_ERROR_LOG }}
|
|
||||||
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
uses: softprops/action-gh-release@v2.6.1
|
uses: softprops/action-gh-release@v3.0.0
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
with:
|
with:
|
||||||
make_latest: false
|
make_latest: false
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
arch: ${{ matrix.arch }}
|
arch: ${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
uses: softprops/action-gh-release@v2.6.1
|
uses: softprops/action-gh-release@v3.0.0
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
with:
|
with:
|
||||||
make_latest: false
|
make_latest: false
|
||||||
|
|||||||
59
.github/workflows/playwright.yml
vendored
59
.github/workflows/playwright.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e-server:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
filter: tree:0
|
filter: tree:0
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
@@ -73,15 +73,66 @@ jobs:
|
|||||||
sleep 10
|
sleep 10
|
||||||
|
|
||||||
- name: Server end-to-end tests
|
- name: Server end-to-end tests
|
||||||
run: pnpm --filter server-e2e e2e
|
run: pnpm --filter server e2e
|
||||||
|
|
||||||
- name: Upload test report
|
- name: Upload test report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: e2e report ${{ matrix.arch }}
|
name: e2e report ${{ matrix.arch }}
|
||||||
path: apps/server-e2e/test-output
|
path: apps/server/test-output
|
||||||
|
|
||||||
- name: Kill the server
|
- name: Kill the server
|
||||||
if: always()
|
if: always()
|
||||||
run: pkill -f trilium || true
|
run: pkill -f trilium || true
|
||||||
|
|
||||||
|
e2e-standalone:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: linux-x64
|
||||||
|
os: ubuntu-22.04
|
||||||
|
- name: linux-arm64
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
name: Standalone E2E tests on ${{ matrix.name }}
|
||||||
|
env:
|
||||||
|
TRILIUM_DOCKER: 1
|
||||||
|
TRILIUM_PORT: 8082
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
filter: tree:0
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v5
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: pnpm exec playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Build standalone
|
||||||
|
run: TRILIUM_INTEGRATION_TEST=memory pnpm --filter client-standalone build
|
||||||
|
|
||||||
|
- name: Start standalone preview server
|
||||||
|
run: |
|
||||||
|
cd apps/client-standalone
|
||||||
|
pnpm vite preview --port $TRILIUM_PORT --host 127.0.0.1 &
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
- name: Standalone end-to-end tests
|
||||||
|
run: pnpm --filter client-standalone e2e
|
||||||
|
|
||||||
|
- name: Upload test report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: standalone e2e report ${{ matrix.name }}
|
||||||
|
path: apps/client-standalone/test-output
|
||||||
|
|||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os.image }}
|
runs-on: ${{ matrix.os.image }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -150,7 +150,7 @@ jobs:
|
|||||||
path: upload
|
path: upload
|
||||||
|
|
||||||
- name: Publish stable release
|
- name: Publish stable release
|
||||||
uses: softprops/action-gh-release@v2.6.1
|
uses: softprops/action-gh-release@v3.0.0
|
||||||
with:
|
with:
|
||||||
draft: false
|
draft: false
|
||||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||||
|
|||||||
4
.github/workflows/web-clipper.yml
vendored
4
.github/workflows/web-clipper.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
compression-level: 0
|
compression-level: 0
|
||||||
|
|
||||||
- name: Release web clipper extension
|
- name: Release web clipper extension
|
||||||
uses: softprops/action-gh-release@v2.6.1
|
uses: softprops/action-gh-release@v3.0.0
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }}
|
if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }}
|
||||||
with:
|
with:
|
||||||
draft: false
|
draft: false
|
||||||
|
|||||||
2
.github/workflows/website.yml
vendored
2
.github/workflows/website.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v6
|
||||||
- name: Set up node & dependencies
|
- name: Set up node & dependencies
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ upload
|
|||||||
site/
|
site/
|
||||||
apps/*/coverage
|
apps/*/coverage
|
||||||
scripts/translation/.language*.json
|
scripts/translation/.language*.json
|
||||||
|
|
||||||
|
# AI
|
||||||
|
.claude/settings.local.json
|
||||||
118
.mailmap
118
.mailmap
@@ -1,2 +1,116 @@
|
|||||||
zadam <adam.zivner@gmail.com>
|
# Format: Canonical Name <canonical-email> <commit-email>
|
||||||
zadam <zadam.apps@gmail.com>
|
# Merges aliases so `git shortlog`, `git log --use-mailmap`, etc. group commits per person.
|
||||||
|
|
||||||
|
# Core maintainers
|
||||||
|
zadam <zadam.apps@gmail.com>
|
||||||
|
zadam <zadam.apps@gmail.com> <adam.zivner@gmail.com>
|
||||||
|
zadam <zadam.apps@gmail.com> <adam.zivner@gemalto.com>
|
||||||
|
|
||||||
|
Elian Doran <contact@eliandoran.me>
|
||||||
|
Elian Doran <contact@eliandoran.me> <online@eliandoran.me>
|
||||||
|
|
||||||
|
Adorian Doran <adorian@esevo.ro>
|
||||||
|
Adorian Doran <adorian@esevo.ro> <adoriandoran@gmail.com>
|
||||||
|
|
||||||
|
# Contributors with multiple emails / name variants
|
||||||
|
Panagiotis Papadopoulos <pano_90@gmx.net> <102623907+pano9000@users.noreply.github.com>
|
||||||
|
|
||||||
|
Jon Fuller <jonfuller2012@gmail.com>
|
||||||
|
|
||||||
|
SiriusXT <1160925501@qq.com>
|
||||||
|
SiriusXT <1160925501@qq.com> <11609255001@qq.com>
|
||||||
|
SiriusXT <1160925501@qq.com> <37627919+SiriusXT@users.noreply.github.com>
|
||||||
|
|
||||||
|
JYC333 <22962980+JYC333@users.noreply.github.com>
|
||||||
|
JYC333 <22962980+JYC333@users.noreply.github.com> <yuchuanjin333@gmail.com>
|
||||||
|
|
||||||
|
Nriver <6752679+Nriver@users.noreply.github.com>
|
||||||
|
|
||||||
|
Francis C. <normitomf@gmail.com>
|
||||||
|
Francis C. <normitomf@gmail.com> <francistw@users.noreply.github.com>
|
||||||
|
|
||||||
|
Thomas Frei <7283497+thfrei@users.noreply.github.com>
|
||||||
|
|
||||||
|
hasecilu <hasecilu@tuta.io>
|
||||||
|
|
||||||
|
meinzzzz <lukas.geiselhart35@gmail.com>
|
||||||
|
|
||||||
|
FliegendeWurst <arne.keller@posteo.de>
|
||||||
|
FliegendeWurst <arne.keller@posteo.de> <2012gdwu@web.de>
|
||||||
|
FliegendeWurst <arne.keller@posteo.de> <2012gdwu+github@posteo.de>
|
||||||
|
|
||||||
|
MeIchthys <github.com@meichthys.com>
|
||||||
|
MeIchthys <github.com@meichthys.com> <10717998+meichthys@users.noreply.github.com>
|
||||||
|
|
||||||
|
Marcel Wiechmann <marcel.wiechmann@gmail.com>
|
||||||
|
Marcel Wiechmann <marcel.wiechmann@gmail.com> <github.y3y0w@sl.wiechmann.at>
|
||||||
|
|
||||||
|
Tomas Adamek <ad.tomik@seznam.cz>
|
||||||
|
Tomas Adamek <ad.tomik@seznam.cz> <50672285+Kureii@users.noreply.github.com>
|
||||||
|
|
||||||
|
soulsands <407221377@qq.com>
|
||||||
|
|
||||||
|
chesspro13 <chesspro13@gmail.com>
|
||||||
|
|
||||||
|
sigaloid <69441971+sigaloid@users.noreply.github.com>
|
||||||
|
|
||||||
|
Marek Lewandowski <m.lewandowski@cksource.com>
|
||||||
|
Marek Lewandowski <m.lewandowski@cksource.com> <code@mlewandowski.com>
|
||||||
|
Marek Lewandowski <m.lewandowski@cksource.com> <mlewand@users.noreply.github.com>
|
||||||
|
|
||||||
|
lzinga <lucas.elzinga@outlook.com>
|
||||||
|
lzinga <lucas.elzinga@outlook.com> <lzinga@users.noreply.github.com>
|
||||||
|
|
||||||
|
Sukant Gujar <sukantgujar@yahoo.com>
|
||||||
|
|
||||||
|
Matt Wilkie <maphew@gmail.com>
|
||||||
|
Matt Wilkie <maphew@gmail.com> <matt.wilkie@yukon.ca>
|
||||||
|
|
||||||
|
Andreas Haan <andreas.mobil1@googlemail.com>
|
||||||
|
|
||||||
|
Potjoe-97 <42873357+Potjoe-97@users.noreply.github.com>
|
||||||
|
Potjoe-97 <42873357+Potjoe-97@users.noreply.github.com> <giann@LAPTOPT490-GF>
|
||||||
|
|
||||||
|
Alex Pietsch <54153428+alexpietsch@users.noreply.github.com>
|
||||||
|
|
||||||
|
Laurent Cozic <laurent@cozic.net>
|
||||||
|
Laurent Cozic <laurent@cozic.net> <laurent22@users.noreply.github.com>
|
||||||
|
|
||||||
|
Zexin Yuan <git@yzx9.xyz>
|
||||||
|
Zexin Yuan <git@yzx9.xyz> <yuan.zx@outlook.com>
|
||||||
|
|
||||||
|
hulmgulm <hulmgulm@users.noreply.github.com>
|
||||||
|
hulmgulm <hulmgulm@users.noreply.github.com> <12165268+hulmgulm@users.noreply.github.com>
|
||||||
|
hulmgulm <hulmgulm@users.noreply.github.com> <github@hulmgulm.de>
|
||||||
|
|
||||||
|
Jules Bertholet <jules.bertholet@gmail.com>
|
||||||
|
|
||||||
|
Charles Dagenais <dagenais.charles@gmail.com>
|
||||||
|
|
||||||
|
Giulia Ye <yg97.cs@gmail.com>
|
||||||
|
|
||||||
|
baddate <37013819+baddate@users.noreply.github.com>
|
||||||
|
|
||||||
|
DerVogel101 <128903814+DerVogel101@users.noreply.github.com>
|
||||||
|
DerVogel101 <128903814+DerVogel101@users.noreply.github.com> <jan.irmer@outlook.de>
|
||||||
|
|
||||||
|
Marcello Fuschi <marcellofuschi1@gmail.com>
|
||||||
|
|
||||||
|
Jiahao Lee <lijiahao34@live.com>
|
||||||
|
|
||||||
|
Dmitry Matveyev <dev@greenfork.me>
|
||||||
|
Dmitry Matveyev <dev@greenfork.me> <info@greenfork.me>
|
||||||
|
|
||||||
|
Grant Zhu <a1065135230@gmail.com>
|
||||||
|
|
||||||
|
Sylvain Pasche <sylvain.pasche@gmail.com>
|
||||||
|
Sylvain Pasche <sylvain.pasche@gmail.com> <spasche@spasche.net>
|
||||||
|
|
||||||
|
mm21 <8033134+mm21@users.noreply.github.com>
|
||||||
|
mm21 <8033134+mm21@users.noreply.github.com> <mm21.dev@gmail.com>
|
||||||
|
|
||||||
|
BeatLink <git@beatlink.simplelogin.com>
|
||||||
|
BeatLink <git@beatlink.simplelogin.com> <github@beatlink.simplelogin.com>
|
||||||
|
|
||||||
|
Florian Meißner <161936+Mystler@users.noreply.github.com>
|
||||||
|
Florian Meißner <161936+Mystler@users.noreply.github.com> <developer@mystler.eu>
|
||||||
|
|||||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"trilium": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://localhost:8080/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
364
CLAUDE.md
364
CLAUDE.md
@@ -2,151 +2,319 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
> **Note**: When updating this file, also update `.github/copilot-instructions.md` to keep both AI coding assistants in sync.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using pnpm, with multiple applications and shared packages.
|
Trilium Notes is a hierarchical note-taking application with synchronization, scripting, and rich text editing. TypeScript monorepo using pnpm with multiple apps and shared packages.
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Setup
|
```bash
|
||||||
- `pnpm install` - Install all dependencies
|
# Setup
|
||||||
- `corepack enable` - Enable pnpm if not available
|
corepack enable && pnpm install
|
||||||
|
|
||||||
### Running Applications
|
# Run
|
||||||
- `pnpm run server:start` - Start development server (http://localhost:8080)
|
pnpm server:start # Dev server at http://localhost:8080
|
||||||
- `pnpm run server:start-prod` - Run server in production mode
|
pnpm desktop:start # Electron dev app
|
||||||
|
pnpm standalone:start # Standalone client dev
|
||||||
|
|
||||||
### Building
|
# Build
|
||||||
- `pnpm run client:build` - Build client application
|
pnpm client:build # Frontend
|
||||||
- `pnpm run server:build` - Build server application
|
pnpm server:build # Backend
|
||||||
- `pnpm run electron:build` - Build desktop application
|
pnpm desktop:build # Electron
|
||||||
|
|
||||||
### Testing
|
# Test
|
||||||
- `pnpm test:all` - Run all tests (parallel + sequential)
|
pnpm test:all # All tests (parallel + sequential)
|
||||||
- `pnpm test:parallel` - Run tests that can run in parallel
|
pnpm test:parallel # Client + most package tests
|
||||||
- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math)
|
pnpm test:sequential # Server, ckeditor5-mermaid, ckeditor5-math (shared DB)
|
||||||
- `pnpm coverage` - Generate coverage reports
|
pnpm --filter server test # Single package tests
|
||||||
|
pnpm coverage # Coverage reports
|
||||||
|
|
||||||
## Architecture Overview
|
# Lint & Format
|
||||||
|
pnpm dev:linter-check # ESLint check
|
||||||
|
pnpm dev:linter-fix # ESLint fix
|
||||||
|
pnpm dev:format-check # Format check (stricter stylistic rules)
|
||||||
|
pnpm dev:format-fix # Format fix
|
||||||
|
pnpm typecheck # TypeScript type check across all projects
|
||||||
|
```
|
||||||
|
|
||||||
### Monorepo Structure
|
**Running a single test file**: `pnpm --filter server test spec/etapi/search.spec.ts`
|
||||||
- **apps/**: Runnable applications
|
|
||||||
- `client/` - Frontend application (shared by server and desktop)
|
|
||||||
- `server/` - Node.js server with web interface
|
|
||||||
- `desktop/` - Electron desktop application
|
|
||||||
- `web-clipper/` - Browser extension for saving web content
|
|
||||||
- Additional tools: `db-compare`, `dump-db`, `edit-docs`
|
|
||||||
|
|
||||||
- **packages/**: Shared libraries
|
## Main Applications
|
||||||
- `commons/` - Shared interfaces and utilities
|
|
||||||
- `ckeditor5/` - Custom rich text editor with Trilium-specific plugins
|
|
||||||
- `codemirror/` - Code editor customizations
|
|
||||||
- `highlightjs/` - Syntax highlighting
|
|
||||||
- Custom CKEditor plugins: `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid`
|
|
||||||
|
|
||||||
### Core Architecture Patterns
|
The four main apps share `packages/trilium-core/` for business logic but differ in runtime:
|
||||||
|
|
||||||
#### Three-Layer Cache System
|
- **client** (`apps/client/`): Preact frontend with jQuery widget system. Shared UI layer used by both server and desktop.
|
||||||
- **Becca** (Backend Cache): Server-side entity cache (`apps/server/src/becca/`)
|
- **server** (`apps/server/`): Node.js backend (Express, better-sqlite3). Serves the client and provides REST/WebSocket APIs.
|
||||||
- **Froca** (Frontend Cache): Client-side mirror of backend data (`apps/client/src/services/froca.ts`)
|
- **desktop** (`apps/desktop/`): Electron wrapper around server + client, running both in a single process.
|
||||||
- **Shaca** (Share Cache): Optimized cache for shared/published notes (`apps/server/src/share/`)
|
- **standalone** (`apps/client-standalone/` + `apps/standalone-desktop/`): Runs the entire stack in the browser — server logic compiled to WASM via sql.js, executed in a service worker. No Node.js dependency at runtime.
|
||||||
|
|
||||||
#### Entity System
|
## Monorepo Structure
|
||||||
Core entities are defined in `apps/server/src/becca/entities/`:
|
|
||||||
- `BNote` - Notes with content and metadata
|
|
||||||
- `BBranch` - Hierarchical relationships between notes (allows multiple parents)
|
|
||||||
- `BAttribute` - Key-value metadata attached to notes
|
|
||||||
- `BRevision` - Note version history
|
|
||||||
- `BOption` - Application configuration
|
|
||||||
|
|
||||||
#### Widget-Based UI
|
```
|
||||||
Frontend uses a widget system (`apps/client/src/widgets/`):
|
apps/
|
||||||
- `BasicWidget` - Base class for all UI components
|
client/ # Preact frontend (shared by server, desktop, standalone)
|
||||||
- `NoteContextAwareWidget` - Widgets that respond to note changes
|
server/ # Node.js backend (Express, better-sqlite3)
|
||||||
- `RightPanelWidget` - Widgets displayed in the right panel
|
desktop/ # Electron (bundles server + client)
|
||||||
|
client-standalone/ # Standalone client (WASM + service workers, no Node.js)
|
||||||
|
standalone-desktop/ # Standalone desktop variant
|
||||||
|
web-clipper/ # Browser extension
|
||||||
|
website/ # Project website
|
||||||
|
db-compare/, dump-db/, edit-docs/, build-docs/, icon-pack-builder/
|
||||||
|
|
||||||
|
packages/
|
||||||
|
trilium-core/ # Core business logic: entities, services, SQL, sync
|
||||||
|
commons/ # Shared interfaces and utilities
|
||||||
|
trilium-e2e/ # Shared Playwright E2E tests
|
||||||
|
ckeditor5/ # Custom rich text editor bundle
|
||||||
|
codemirror/ # Code editor integration
|
||||||
|
highlightjs/ # Syntax highlighting
|
||||||
|
share-theme/ # Theme for shared/published notes
|
||||||
|
ckeditor5-admonition/, ckeditor5-footnotes/, ckeditor5-math/, ckeditor5-mermaid/
|
||||||
|
ckeditor5-keyboard-marker/, express-partial-content/, pdfjs-viewer/, splitjs/
|
||||||
|
turndown-plugin-gfm/
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `pnpm --filter <package-name> <command>` to run commands in specific packages.
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
### Three-Layer Cache System
|
||||||
|
|
||||||
|
All data access goes through cache layers — never bypass with direct DB queries:
|
||||||
|
|
||||||
|
- **Becca** (`packages/trilium-core/src/becca/`): Server-side entity cache. Access via `becca.notes[noteId]`.
|
||||||
|
- **Froca** (`apps/client/src/services/froca.ts`): Client-side mirror synced via WebSocket. Access via `froca.getNote()`.
|
||||||
|
- **Shaca** (`apps/server/src/share/`): Optimized cache for shared/published notes.
|
||||||
|
|
||||||
|
**Critical**: Always use cache methods, not direct DB writes. Cache methods create `EntityChange` records needed for synchronization.
|
||||||
|
|
||||||
|
### Entity System
|
||||||
|
|
||||||
|
Core entities live in `packages/trilium-core/src/becca/entities/` (not `apps/server/`):
|
||||||
|
|
||||||
|
- `BNote` — Notes with content and metadata
|
||||||
|
- `BBranch` — Multi-parent tree relationships (cloning supported)
|
||||||
|
- `BAttribute` — Key-value metadata (labels and relations)
|
||||||
|
- `BRevision` — Version history
|
||||||
|
- `BOption` — Application configuration
|
||||||
|
- `BBlob` — Binary content storage
|
||||||
|
|
||||||
|
Entities extend `AbstractBeccaEntity<T>` with built-in change tracking, hash generation, and date management.
|
||||||
|
|
||||||
|
### Entity Change & Sync Protocol
|
||||||
|
|
||||||
|
Every entity modification creates an `EntityChange` record driving sync:
|
||||||
|
1. Login with HMAC authentication (document secret + timestamp)
|
||||||
|
2. Push changes → Pull changes → Push again (conflict resolution)
|
||||||
|
3. Content hash verification with retry loop
|
||||||
|
|
||||||
|
Sync services: `packages/trilium-core/src/services/sync.ts`, `syncMutexService`, `syncUpdateService`.
|
||||||
|
|
||||||
|
### Widget-Based UI
|
||||||
|
|
||||||
|
Frontend widgets in `apps/client/src/widgets/`:
|
||||||
|
- `BasicWidget` / `TypedBasicWidget` — Base classes (jQuery `this.$widget` for DOM)
|
||||||
|
- `NoteContextAwareWidget` — Responds to note changes
|
||||||
|
- `RightPanelWidget` — Sidebar widgets with position ordering
|
||||||
- Type-specific widgets in `type_widgets/` directory
|
- Type-specific widgets in `type_widgets/` directory
|
||||||
|
|
||||||
#### API Architecture
|
**Widget lifecycle**: `doRenderBody()` for initial render, `refreshWithNote()` for note changes, `entitiesReloadedEvent({loadResults})` for entity updates. Uses jQuery — don't mix React patterns.
|
||||||
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
|
|
||||||
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
|
|
||||||
- **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`)
|
|
||||||
|
|
||||||
### Key Files for Understanding Architecture
|
#### Reusable Preact Components
|
||||||
|
Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating custom implementations:
|
||||||
|
- `NoItems` - Empty state placeholder with icon and message (use for "no results", "too many items", error states)
|
||||||
|
- `ActionButton` - Consistent button styling with icon support
|
||||||
|
- `FormTextBox` - Text input with validation and controlled input handling
|
||||||
|
- `Slider` - Range slider with label
|
||||||
|
- `Checkbox`, `RadioButton` - Form controls
|
||||||
|
- `CollapsibleSection` - Expandable content sections
|
||||||
|
|
||||||
1. **Application Entry Points**:
|
Fluent builder pattern: `.child()`, `.class()`, `.css()` chaining with position-based ordering.
|
||||||
- `apps/server/src/main.ts` - Server startup
|
|
||||||
- `apps/client/src/desktop.ts` - Client initialization
|
|
||||||
|
|
||||||
2. **Core Services**:
|
### API Architecture
|
||||||
- `apps/server/src/becca/becca.ts` - Backend data management
|
|
||||||
- `apps/client/src/services/froca.ts` - Frontend data synchronization
|
|
||||||
- `apps/server/src/services/backend_script_api.ts` - Scripting API
|
|
||||||
|
|
||||||
3. **Database Schema**:
|
- **Internal API** (`apps/server/src/routes/api/`): REST endpoints, trusts frontend
|
||||||
- `apps/server/src/assets/db/schema.sql` - Core database structure
|
- **ETAPI** (`apps/server/src/etapi/`): External API with basic auth tokens — maintain backwards compatibility
|
||||||
|
- **WebSocket** (`apps/server/src/services/ws.ts`): Real-time sync
|
||||||
|
|
||||||
4. **Configuration**:
|
### Platform Abstraction
|
||||||
- `package.json` - Project dependencies and scripts
|
|
||||||
|
|
||||||
## Note Types and Features
|
`packages/trilium-core/src/services/platform.ts` defines `PlatformProvider` interface with implementations in `apps/desktop/`, `apps/server/`, and `apps/client-standalone/`. Singleton via `initPlatform()`/`getPlatform()`.
|
||||||
|
|
||||||
Trilium supports multiple note types, each with specialized widgets:
|
**PlatformProvider** provides:
|
||||||
- **Text**: Rich text with CKEditor5 (markdown import/export)
|
- `crash(message)` — Platform-specific fatal error handling
|
||||||
- **Code**: Syntax-highlighted code editing with CodeMirror
|
- `getEnv(key)` — Environment variable access (server/desktop use `process.env`, standalone maps URL query params like `?safeMode` → `TRILIUM_SAFE_MODE`)
|
||||||
- **File**: Binary file attachments
|
- `isElectron`, `isMac`, `isWindows` — Platform detection flags
|
||||||
- **Image**: Image display with editing capabilities
|
|
||||||
- **Canvas**: Drawing/diagramming with Excalidraw
|
|
||||||
- **Mermaid**: Diagram generation
|
|
||||||
- **Relation Map**: Visual note relationship mapping
|
|
||||||
- **Web View**: Embedded web pages
|
|
||||||
- **Doc/Book**: Hierarchical documentation structure
|
|
||||||
|
|
||||||
## Development Guidelines
|
**Critical rules for `trilium-core`**:
|
||||||
|
- **No `process.env` in core** — use `getPlatform().getEnv()` instead (not available in standalone/browser)
|
||||||
|
- **No `import path from "path"` in core** — Node's `path` module is externalized in browser builds. Use `packages/trilium-core/src/services/utils/path.ts` for `extname()`/`basename()` equivalents
|
||||||
|
- **No Node.js built-in modules in core** — core runs in both Node.js and the browser (standalone). Use platform-agnostic alternatives or platform providers
|
||||||
|
- **Platform detection via functions** — `isElectron()`, `isMac()`, `isWindows()` from `utils/index.ts` are functions (not constants) that call `getPlatform()`. They can only be called after `initializeCore()`, not at module top-level. If used in static definitions, wrap in a closure: `value: () => isWindows() ? "0.9" : "1.0"`
|
||||||
|
- **Barrel import caution** — `import { x } from "@triliumnext/core"` loads ALL core exports. Early-loading modules like `config.ts` should import specific subpaths (e.g. `@triliumnext/core/src/services/utils/index`) to avoid circular dependencies or initialization ordering issues
|
||||||
|
- **Electron IPC** — In desktop mode, client API calls use Electron IPC (not HTTP). The IPC handler in `apps/server/src/routes/electron.ts` must be registered via `utils.isElectron` from the **server's** utils (which correctly checks `process.versions["electron"]`), not from core's utils
|
||||||
|
|
||||||
|
### Binary Utilities
|
||||||
|
|
||||||
|
Use utilities from `packages/trilium-core/src/services/utils/binary.ts` for string/buffer conversions instead of manual `TextEncoder`/`TextDecoder` or `Buffer.from()` calls:
|
||||||
|
|
||||||
|
- **`wrapStringOrBuffer(input)`** — Converts `string` to `Uint8Array`, returns `Uint8Array` unchanged. Use when a function expects `Uint8Array` but receives `string | Uint8Array`.
|
||||||
|
- **`unwrapStringOrBuffer(input)`** — Converts `Uint8Array` to `string`, returns `string` unchanged. Use when a function expects `string` but receives `string | Uint8Array`.
|
||||||
|
- **`encodeBase64(input)`** / **`decodeBase64(input)`** — Base64 encoding/decoding that works in both Node.js and browser.
|
||||||
|
- **`encodeUtf8(string)`** / **`decodeUtf8(buffer)`** — UTF-8 encoding/decoding.
|
||||||
|
|
||||||
|
Import via `import { binary_utils } from "@triliumnext/core"` or directly from the module.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/services/sql/` with `DatabaseProvider` interface, prepared statement caching, and transaction support.
|
||||||
|
|
||||||
|
- Schema: `apps/server/src/assets/db/schema.sql`
|
||||||
|
- Migrations: `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
|
||||||
|
|
||||||
### Testing Strategy
|
### Testing Strategy
|
||||||
- Server tests run sequentially due to shared database
|
- Server tests run sequentially due to shared database
|
||||||
- Client tests can run in parallel
|
- Client tests can run in parallel
|
||||||
- E2E tests use Playwright for both server and desktop apps
|
- E2E tests use Playwright for both server and desktop apps
|
||||||
- Build validation tests check artifact integrity
|
- Build validation tests check artifact integrity
|
||||||
|
- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests
|
||||||
### Scripting System
|
- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it
|
||||||
Trilium provides powerful user scripting capabilities:
|
|
||||||
- Frontend scripts run in browser context
|
|
||||||
- Backend scripts run in Node.js context with full API access
|
|
||||||
- Script API documentation available in `docs/Script API/`
|
|
||||||
|
|
||||||
### Internationalization
|
### Internationalization
|
||||||
- Translation files in `apps/client/src/translations/`
|
- Translation files in `apps/client/src/translations/`
|
||||||
- Supported languages: English, German, Spanish, French, Romanian, Chinese
|
- Supported languages: English, German, Spanish, French, Romanian, Chinese
|
||||||
|
- **Only add new translation keys to `en/translation.json`** — translations for other languages are managed via Weblate and will be contributed by the community
|
||||||
|
- Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`)
|
||||||
|
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
|
||||||
|
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
|
||||||
|
- **Server-side translations** (e.g. hidden subtree titles) go in `apps/server/src/assets/translations/en/server.json`, not in the client `translation.json`
|
||||||
|
|
||||||
### Security Considerations
|
#### Client vs Server Translation Usage
|
||||||
- Per-note encryption with granular protected sessions
|
- **Client-side**: `import { t } from "../services/i18n"` with keys in `apps/client/src/translations/en/translation.json`
|
||||||
- CSRF protection for API endpoints
|
- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json`
|
||||||
- OpenID and TOTP authentication support
|
- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped
|
||||||
- Sanitization of user-generated content
|
|
||||||
|
|
||||||
## Common Development Tasks
|
### Electron Desktop App
|
||||||
|
- Desktop entry point: `apps/desktop/src/main.ts`, window management: `apps/server/src/services/window.ts`
|
||||||
|
- IPC communication: use `electron.ipcMain.on(channel, handler)` on server side, `electron.ipcRenderer.send(channel, data)` on client side
|
||||||
|
- Electron-only features should check `isElectron()` from `apps/client/src/services/utils.ts` (client) or `utils.isElectron` (server)
|
||||||
|
|
||||||
### Adding New Note Types
|
Three inheritance mechanisms:
|
||||||
1. Create widget in `apps/client/src/widgets/type_widgets/`
|
1. **Standard**: `note.getInheritableAttributes()` walks parent tree
|
||||||
2. Register in `apps/client/src/services/note_types.ts`
|
2. **Child prefix**: `child:label` on parent copies to children
|
||||||
3. Add backend handling in `apps/server/src/services/notes.ts`
|
3. **Template relation**: `#template=noteNoteId` includes template's inheritable attributes
|
||||||
|
|
||||||
### Extending Search
|
### Attribute Inheritance
|
||||||
- Search expressions handled in `apps/server/src/services/search/`
|
|
||||||
- Add new search operators in search context files
|
|
||||||
|
|
||||||
### Custom CKEditor Plugins
|
Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
|
||||||
- Create new package in `packages/` following existing plugin structure
|
### Client-Side API Restrictions
|
||||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
- **Do not use `crypto.randomUUID()`** or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS
|
||||||
|
- Use `randomString()` from `apps/client/src/services/utils.ts` for generating IDs instead
|
||||||
|
|
||||||
|
### Storing User Preferences
|
||||||
|
- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices
|
||||||
|
- To add a new user preference:
|
||||||
|
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
|
||||||
|
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
|
||||||
|
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to the `ALLOWED_OPTIONS` array — **without this, the API will reject changes with "Option 'X' is not allowed to be changed"**
|
||||||
|
4. If the option should be user-editable in the UI, add a control in the appropriate settings component (e.g., `apps/client/src/widgets/type_widgets/options/other.tsx`) and a translation key in `apps/client/src/translations/en/translation.json`
|
||||||
|
5. Use `useTriliumOption("optionName")` hook in React components to read/write the option
|
||||||
|
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
|
||||||
|
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
|
||||||
|
|
||||||
|
### Shared Types Policy
|
||||||
|
- Types shared between client and server belong in `@triliumnext/commons` (`packages/commons/src/lib/`)
|
||||||
|
- Import shared types directly from `@triliumnext/commons` - do not re-export them from app-specific modules
|
||||||
|
- Keep app-specific types (e.g., `LlmProvider` for server, `StreamCallbacks` for client) in their respective apps
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
- **Protected notes**: Check `note.isContentAvailable()` before accessing content; use `note.getTitleOrProtected()` for safe title access
|
||||||
|
- **Long operations**: Use `TaskContext` for progress reporting via WebSocket
|
||||||
|
- **Event system** (`packages/trilium-core/src/services/events.ts`): Events emitted in order (notes → branches → attributes) during load for referential integrity
|
||||||
|
- **Search**: Expression-based, scoring happens in-memory — cannot add SQL-level LIMIT/OFFSET without losing scoring
|
||||||
|
- **Widget cleanup**: Unsubscribe from events in `cleanup()`/`doDestroy()` to prevent memory leaks
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- 4-space indentation, semicolons always required
|
||||||
|
- Double quotes (enforced by format config)
|
||||||
|
- Max line length: 100 characters
|
||||||
|
- Unix line endings
|
||||||
|
- Import sorting via `eslint-plugin-simple-import-sort`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Server tests** (`apps/server/spec/`): Vitest, must run sequentially (shared DB), forks pool, max 6 workers
|
||||||
|
- **Client tests** (`apps/client/src/`): Vitest with happy-dom environment, can run in parallel
|
||||||
|
- **E2E tests** (`packages/trilium-e2e/`): Shared Playwright tests, run via `pnpm --filter server e2e` or `pnpm --filter client-standalone e2e`
|
||||||
|
- **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- `docs/Script API/` — Auto-generated, never edit directly
|
||||||
|
- `docs/User Guide/` — Edit via `pnpm edit-docs:edit-docs`, not manually
|
||||||
|
- `docs/Developer Guide/` and `docs/Release Notes/` — Safe for direct Markdown editing
|
||||||
|
|
||||||
|
## Key Entry Points
|
||||||
|
|
||||||
|
- `apps/server/src/main.ts` — Server startup
|
||||||
|
- `apps/client/src/desktop.ts` — Client initialization
|
||||||
|
- `packages/trilium-core/src/becca/becca.ts` — Backend data management
|
||||||
|
- `apps/client/src/services/froca.ts` — Frontend cache
|
||||||
|
- `apps/server/src/routes/routes.ts` — API route registration
|
||||||
|
- `packages/trilium-core/src/services/sql/sql.ts` — Database abstraction
|
||||||
|
|
||||||
|
### Adding Hidden System Notes
|
||||||
|
The hidden subtree (`_hidden`) contains system notes with predictable IDs (prefixed with `_`). Defined in `apps/server/src/services/hidden_subtree.ts` via the `HiddenSubtreeItem` interface from `@triliumnext/commons`.
|
||||||
|
|
||||||
|
1. Add the note definition to `buildHiddenSubtreeDefinition()` in `apps/server/src/services/hidden_subtree.ts`
|
||||||
|
2. Add a translation key for the title in `apps/server/src/assets/translations/en/server.json` under `"hidden-subtree"`
|
||||||
|
3. The note is auto-created on startup by `checkHiddenSubtree()` — uses deterministic IDs so all sync cluster instances generate the same structure
|
||||||
|
4. Key properties: `id` (must start with `_`), `title`, `type`, `icon` (format: `bx-icon-name` without `bx ` prefix), `attributes`, `children`, `content`
|
||||||
|
5. Use `enforceAttributes: true` to keep attributes in sync, `enforceBranches: true` for correct placement, `enforceDeleted: true` to remove deprecated notes
|
||||||
|
6. For launcher bar entries, see `hidden_subtree_launcherbar.ts`; for templates, see `hidden_subtree_templates.ts`
|
||||||
|
|
||||||
|
### Writing to Notes from Server Services
|
||||||
|
- `note.setContent()` requires a CLS (Continuation Local Storage) context — wrap calls in `cls.init(() => { ... })` (from `apps/server/src/services/cls.ts`)
|
||||||
|
- Operations called from Express routes already have CLS context; standalone services (schedulers, Electron IPC handlers) do not
|
||||||
|
|
||||||
|
### Adding New LLM Tools
|
||||||
|
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
|
||||||
|
|
||||||
|
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `attachment_tools.ts`, `hierarchy_tools.ts`) or create a new module
|
||||||
|
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations
|
||||||
|
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
|
||||||
|
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
|
||||||
|
5. Use ETAPI (`apps/server/src/etapi/`) as inspiration for what fields to expose, but **do not import ETAPI mappers** — inline the field mappings directly in the tool so the LLM layer stays decoupled from the API layer
|
||||||
|
|
||||||
|
### Updating PDF.js
|
||||||
|
1. Update `pdfjs-dist` version in `packages/pdfjs-viewer/package.json`
|
||||||
|
2. Run `npx tsx scripts/update-viewer.ts` from that directory
|
||||||
|
3. Run `pnpm build` to verify success
|
||||||
|
4. Commit all changes including updated viewer files
|
||||||
|
|
||||||
### Database Migrations
|
### Database Migrations
|
||||||
- Add migration scripts in `apps/server/src/migrations/`
|
- Add migration scripts in `apps/server/src/migrations/`
|
||||||
- Update schema in `apps/server/src/assets/db/schema.sql`
|
- Update schema in `apps/server/src/assets/db/schema.sql`
|
||||||
|
|
||||||
|
### Server-Side Static Assets
|
||||||
|
- Static assets (templates, SQL, translations, etc.) go in `apps/server/src/assets/`
|
||||||
|
- Access them at runtime via `RESOURCE_DIR` from `apps/server/src/services/resource_dir.ts` (e.g. `path.join(RESOURCE_DIR, "llm", "skills", "file.md")`)
|
||||||
|
- **Do not use `import.meta.url`/`fileURLToPath`** to resolve file paths — the server is bundled into CJS for production, so `import.meta.url` will not point to the source directory
|
||||||
|
- **Do not use `__dirname` with relative paths** from source files — after bundling, `__dirname` points to the bundle output, not the original source tree
|
||||||
|
|
||||||
|
## MCP Server
|
||||||
|
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
|
||||||
|
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
|
||||||
|
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
|
||||||
|
- Use it to interact with actual note data when developing or debugging note-related features
|
||||||
|
|
||||||
## Build System Notes
|
## Build System Notes
|
||||||
- Uses pnpm for monorepo management
|
- Uses pnpm for monorepo management
|
||||||
- Vite for fast development builds
|
- Vite for fast development builds
|
||||||
- ESBuild for production optimization
|
- ESBuild for production optimization
|
||||||
- pnpm workspaces for dependency management
|
- pnpm workspaces for dependency management
|
||||||
- Docker support with multi-stage builds
|
- Docker support with multi-stage builds
|
||||||
|
|||||||
84
SECURITY.md
84
SECURITY.md
@@ -2,13 +2,87 @@
|
|||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
In the (still active) 0.X phase of the project only the latest stable minor release is getting bugfixes (including security ones).
|
Only the latest stable minor release receives security fixes.
|
||||||
|
|
||||||
So e.g. if the latest stable version is 0.42.3 and the latest beta version is 0.43.0-beta, then 0.42 line will still get security fixes but older versions (like 0.41.X) won't get any fixes.
|
For example, if the latest stable version is 0.92.3 and the latest beta is 0.93.0-beta, then only the 0.92.x line will receive security patches. Older versions (like 0.91.x) will not receive fixes.
|
||||||
|
|
||||||
Description above is a general rule and may be altered on case by case basis.
|
This policy may be altered on a case-by-case basis for critical vulnerabilities.
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
* For low severity vulnerabilities, they can be reported as GitHub issues.
|
**Please report all security vulnerabilities through [GitHub Security Advisories](https://github.com/TriliumNext/Notes/security/advisories/new).**
|
||||||
* For severe vulnerabilities, please report it using [GitHub Security Advisories](https://github.com/TriliumNext/Trilium/security/advisories).
|
|
||||||
|
We do not accept security reports via email, public issues, or other channels. GitHub Security Advisories allows us to:
|
||||||
|
- Discuss and triage vulnerabilities privately
|
||||||
|
- Coordinate fixes before public disclosure
|
||||||
|
- Credit reporters appropriately
|
||||||
|
- Publish advisories with CVE identifiers
|
||||||
|
|
||||||
|
### What to Include
|
||||||
|
|
||||||
|
When reporting, please provide:
|
||||||
|
- A clear description of the vulnerability
|
||||||
|
- Steps to reproduce or proof-of-concept
|
||||||
|
- Affected versions (if known)
|
||||||
|
- Potential impact assessment
|
||||||
|
- Any suggested mitigations or fixes
|
||||||
|
|
||||||
|
### Response Timeline
|
||||||
|
|
||||||
|
- **Initial response**: Within 7 days
|
||||||
|
- **Triage decision**: Within 14 days
|
||||||
|
- **Fix timeline**: Depends on severity and complexity
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- Remote code execution
|
||||||
|
- Authentication/authorization bypass
|
||||||
|
- Cross-site scripting (XSS) that affects other users
|
||||||
|
- SQL injection
|
||||||
|
- Path traversal
|
||||||
|
- Sensitive data exposure
|
||||||
|
- Privilege escalation
|
||||||
|
|
||||||
|
### Out of Scope (Won't Fix)
|
||||||
|
|
||||||
|
The following are considered out of scope or accepted risks:
|
||||||
|
|
||||||
|
#### Self-XSS / Self-Injection
|
||||||
|
Trilium is a personal knowledge base where users have full control over their own data. Users can intentionally create notes containing scripts, HTML, or other executable content. This is by design - Trilium's scripting system allows users to extend functionality with custom JavaScript.
|
||||||
|
|
||||||
|
Vulnerabilities that require a user to inject malicious content into their own notes and then view it themselves are not considered security issues.
|
||||||
|
|
||||||
|
#### Electron Architecture (nodeIntegration)
|
||||||
|
Trilium's desktop application runs with `nodeIntegration: true` to enable its powerful scripting features. This is an intentional design decision, similar to VS Code extensions having full system access. We mitigate risks by:
|
||||||
|
- Sanitizing content at input boundaries
|
||||||
|
- Fixing specific XSS vectors as they're discovered
|
||||||
|
- Using Electron fuses to prevent external abuse
|
||||||
|
|
||||||
|
#### Authenticated User Actions
|
||||||
|
Actions that require valid authentication and only affect the authenticated user's own data are generally not vulnerabilities.
|
||||||
|
|
||||||
|
#### Denial of Service via Resource Exhaustion
|
||||||
|
Creating extremely large notes or performing many operations is expected user behavior in a note-taking application.
|
||||||
|
|
||||||
|
#### Missing Security Headers on Non-Sensitive Endpoints
|
||||||
|
We implement security headers where they provide meaningful protection, but may omit them on endpoints where they provide no practical benefit.
|
||||||
|
|
||||||
|
## Coordinated Disclosure
|
||||||
|
|
||||||
|
We follow a coordinated disclosure process:
|
||||||
|
|
||||||
|
1. **Report received** - We acknowledge receipt and begin triage
|
||||||
|
2. **Fix developed** - We develop and test a fix privately
|
||||||
|
3. **Release prepared** - Security release is prepared with vague changelog
|
||||||
|
4. **Users notified** - Release is published, users encouraged to upgrade
|
||||||
|
5. **Advisory published** - After reasonable upgrade window (typically 2-4 weeks), full advisory is published
|
||||||
|
|
||||||
|
We appreciate reporters allowing us time to fix issues before public disclosure. We aim to credit all reporters in published advisories unless they prefer to remain anonymous.
|
||||||
|
|
||||||
|
## Security Updates
|
||||||
|
|
||||||
|
Security fixes are released as patch versions (e.g., 0.92.1 → 0.92.2) to minimize upgrade friction. We recommend all users keep their installations up to date.
|
||||||
|
|
||||||
|
Subscribe to GitHub releases or watch the repository to receive notifications of new releases.
|
||||||
|
|||||||
@@ -15,14 +15,16 @@
|
|||||||
"author": "Elian Doran <contact@eliandoran.me>",
|
"author": "Elian Doran <contact@eliandoran.me>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@triliumnext/core": "workspace:*",
|
||||||
|
"@triliumnext/server": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@redocly/cli": "2.25.1",
|
"@redocly/cli": "2.28.0",
|
||||||
"archiver": "7.0.1",
|
"archiver": "7.0.1",
|
||||||
"fs-extra": "11.3.4",
|
"fs-extra": "11.3.4",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"react": "19.2.4",
|
"typedoc": "0.28.19",
|
||||||
"react-dom": "19.2.4",
|
"typedoc-plugin-missing-exports": "4.1.3"
|
||||||
"typedoc": "0.28.18",
|
|
||||||
"typedoc-plugin-missing-exports": "4.1.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,21 +14,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
default as AbstractBeccaEntity
|
AbstractBeccaEntity,
|
||||||
} from "../../server/src/becca/entities/abstract_becca_entity.js";
|
BAttachment,
|
||||||
export type {
|
BAttribute,
|
||||||
default as BAttachment
|
BBranch,
|
||||||
} from "../../server/src/becca/entities/battachment.js";
|
BEtapiToken,
|
||||||
export type { default as BAttribute } from "../../server/src/becca/entities/battribute.js";
|
BNote,
|
||||||
export type { default as BBranch } from "../../server/src/becca/entities/bbranch.js";
|
BOption,
|
||||||
export type { default as BEtapiToken } from "../../server/src/becca/entities/betapi_token.js";
|
BRecentNote,
|
||||||
export type { BNote };
|
BRevision
|
||||||
export type { default as BOption } from "../../server/src/becca/entities/boption.js";
|
} from "@triliumnext/core";
|
||||||
export type { default as BRecentNote } from "../../server/src/becca/entities/brecent_note.js";
|
|
||||||
export type { default as BRevision } from "../../server/src/becca/entities/brevision.js";
|
|
||||||
|
|
||||||
import BNote from "../../server/src/becca/entities/bnote.js";
|
import { BNote, BackendScriptApi, type BackendScriptApiInterface as Api } from "@triliumnext/core";
|
||||||
import BackendScriptApi, { type Api } from "../../server/src/services/backend_script_api.js";
|
|
||||||
|
|
||||||
export type { Api };
|
export type { Api };
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,43 @@ if (!process.env.TRILIUM_RESOURCE_DIR) {
|
|||||||
}
|
}
|
||||||
process.env.NODE_ENV = "development";
|
process.env.NODE_ENV = "development";
|
||||||
|
|
||||||
import cls from "@triliumnext/server/src/services/cls.js";
|
import { BackupService, getContext, initializeCore, type ImageProvider } from "@triliumnext/core";
|
||||||
|
import ClsHookedExecutionContext from "@triliumnext/server/src/cls_provider.js";
|
||||||
|
import NodejsCryptoProvider from "@triliumnext/server/src/crypto_provider.js";
|
||||||
|
import ServerPlatformProvider from "@triliumnext/server/src/platform_provider.js";
|
||||||
|
import BetterSqlite3Provider from "@triliumnext/server/src/sql_provider.js";
|
||||||
|
import NodejsZipProvider from "@triliumnext/server/src/zip_provider.js";
|
||||||
|
|
||||||
|
// Stub backup service for build-docs (not used, but required by initializeCore)
|
||||||
|
class StubBackupService extends BackupService {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
getOption: () => "",
|
||||||
|
getOptionBool: () => false,
|
||||||
|
setOption: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async backupNow(_name: string): Promise<string> {
|
||||||
|
throw new Error("Backup not supported in build-docs");
|
||||||
|
}
|
||||||
|
async getExistingBackups() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getBackupContent(_filePath: string): Promise<Uint8Array | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub image provider for build-docs (not used, but required by initializeCore)
|
||||||
|
const stubImageProvider: ImageProvider = {
|
||||||
|
getImageType: () => null,
|
||||||
|
processImage: async () => {
|
||||||
|
throw new Error("Image processing not supported in build-docs");
|
||||||
|
}
|
||||||
|
};
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
import { WriteStream } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import * as fsExtra from "fs-extra";
|
import * as fsExtra from "fs-extra";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
@@ -16,6 +49,37 @@ import { dirname, join, resolve } from "path";
|
|||||||
|
|
||||||
import BuildContext from "./context.js";
|
import BuildContext from "./context.js";
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
async function initializeBuildEnvironment() {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
const dbProvider = new BetterSqlite3Provider();
|
||||||
|
dbProvider.loadFromMemory();
|
||||||
|
|
||||||
|
const { serverZipExportProviderFactory } = await import("@triliumnext/server/src/services/export/zip/factory.js");
|
||||||
|
|
||||||
|
await initializeCore({
|
||||||
|
dbConfig: {
|
||||||
|
provider: dbProvider,
|
||||||
|
isReadOnly: false,
|
||||||
|
onTransactionCommit: () => {},
|
||||||
|
onTransactionRollback: () => {}
|
||||||
|
},
|
||||||
|
crypto: new NodejsCryptoProvider(),
|
||||||
|
zip: new NodejsZipProvider(),
|
||||||
|
zipExportProviderFactory: serverZipExportProviderFactory,
|
||||||
|
executionContext: new ClsHookedExecutionContext(),
|
||||||
|
platform: new ServerPlatformProvider(),
|
||||||
|
schema: readFileSync(require.resolve("@triliumnext/core/src/assets/schema.sql"), "utf-8"),
|
||||||
|
translations: (await import("@triliumnext/server/src/services/i18n.js")).initializeTranslations,
|
||||||
|
getDemoArchive: async () => null,
|
||||||
|
backup: new StubBackupService(),
|
||||||
|
image: stubImageProvider
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface NoteMapping {
|
interface NoteMapping {
|
||||||
rootNoteId: string;
|
rootNoteId: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -72,9 +136,8 @@ async function exportDocs(
|
|||||||
) {
|
) {
|
||||||
const zipFilePath = `output-${noteId}.zip`;
|
const zipFilePath = `output-${noteId}.zip`;
|
||||||
try {
|
try {
|
||||||
const { exportToZipFile } = (await import("@triliumnext/server/src/services/export/zip.js"))
|
const { zipExportService } = await import("@triliumnext/core");
|
||||||
.default;
|
await zipExportService.exportToZipFile(noteId, format, zipFilePath, {});
|
||||||
await exportToZipFile(noteId, format, zipFilePath, {});
|
|
||||||
|
|
||||||
const ignoredSet = ignoredFiles ? new Set(ignoredFiles) : undefined;
|
const ignoredSet = ignoredFiles ? new Set(ignoredFiles) : undefined;
|
||||||
await extractZip(zipFilePath, outputPath, ignoredSet);
|
await extractZip(zipFilePath, outputPath, ignoredSet);
|
||||||
@@ -92,18 +155,12 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
|
|||||||
const zipName = outputSubDir || "user-guide";
|
const zipName = outputSubDir || "user-guide";
|
||||||
const zipFilePath = `output-${zipName}.zip`;
|
const zipFilePath = `output-${zipName}.zip`;
|
||||||
try {
|
try {
|
||||||
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js"))
|
const { zipExportService, TaskContext } = await import("@triliumnext/core");
|
||||||
.default;
|
|
||||||
const branch = note.getParentBranches()[0];
|
|
||||||
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js"))
|
|
||||||
.default(
|
|
||||||
"no-progress-reporting",
|
|
||||||
"export",
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const fileOutputStream = fsExtra.createWriteStream(zipFilePath);
|
|
||||||
await exportToZip(taskContext, branch, "share", fileOutputStream);
|
|
||||||
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
|
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
|
||||||
|
const branch = note.getParentBranches()[0];
|
||||||
|
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||||
|
const fileOutputStream = fsExtra.createWriteStream(zipFilePath);
|
||||||
|
await zipExportService.exportToZip(taskContext, branch, "share", fileOutputStream);
|
||||||
await waitForStreamToFinish(fileOutputStream);
|
await waitForStreamToFinish(fileOutputStream);
|
||||||
|
|
||||||
// Output to root directory if outputSubDir is empty, otherwise to subdirectory
|
// Output to root directory if outputSubDir is empty, otherwise to subdirectory
|
||||||
@@ -117,15 +174,11 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildDocsInner(config?: Config) {
|
async function buildDocsInner(config?: Config) {
|
||||||
const i18n = await import("@triliumnext/server/src/services/i18n.js");
|
const { sql_init, becca_loader } = await import("@triliumnext/core");
|
||||||
await i18n.initializeTranslations();
|
await sql_init.createInitialDatabase(true);
|
||||||
|
|
||||||
const sqlInit = (await import("../../server/src/services/sql_init.js")).default;
|
|
||||||
await sqlInit.createInitialDatabase(true);
|
|
||||||
|
|
||||||
// Wait for becca to be loaded before importing data
|
// Wait for becca to be loaded before importing data
|
||||||
const beccaLoader = await import("../../server/src/becca/becca_loader.js");
|
await becca_loader.beccaLoaded;
|
||||||
await beccaLoader.beccaLoaded;
|
|
||||||
|
|
||||||
if (config) {
|
if (config) {
|
||||||
// Config-based build (reads from edit-docs-config.yaml)
|
// Config-based build (reads from edit-docs-config.yaml)
|
||||||
@@ -176,16 +229,14 @@ async function buildDocsInner(config?: Config) {
|
|||||||
|
|
||||||
export async function importData(path: string) {
|
export async function importData(path: string) {
|
||||||
const buffer = await createImportZip(path);
|
const buffer = await createImportZip(path);
|
||||||
const importService = (await import("../../server/src/services/import/zip.js")).default;
|
const { zipImportService, TaskContext, becca } = await import("@triliumnext/core");
|
||||||
const TaskContext = (await import("../../server/src/services/task_context.js")).default;
|
|
||||||
const context = new TaskContext("no-progress-reporting", "importNotes", null);
|
const context = new TaskContext("no-progress-reporting", "importNotes", null);
|
||||||
const becca = (await import("../../server/src/becca/becca.js")).default;
|
|
||||||
|
|
||||||
const rootNote = becca.getRoot();
|
const rootNote = becca.getRoot();
|
||||||
if (!rootNote) {
|
if (!rootNote) {
|
||||||
throw new Error("Missing root note for import.");
|
throw new Error("Missing root note for import.");
|
||||||
}
|
}
|
||||||
return await importService.importZip(context, buffer, rootNote, {
|
return await zipImportService.importZip(context, buffer, rootNote, {
|
||||||
preserveIds: true
|
preserveIds: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -218,20 +269,16 @@ export async function extractZip(
|
|||||||
outputPath: string,
|
outputPath: string,
|
||||||
ignoredFiles?: Set<string>
|
ignoredFiles?: Set<string>
|
||||||
) {
|
) {
|
||||||
const { readZipFile, readContent } = (await import(
|
const { getZipProvider } = await import("@triliumnext/core");
|
||||||
"@triliumnext/server/src/services/import/zip.js"
|
await getZipProvider().readZipFile(await fs.readFile(zipFilePath), async (entry, readContent) => {
|
||||||
));
|
|
||||||
await readZipFile(await fs.readFile(zipFilePath), async (zip, entry) => {
|
|
||||||
// We ignore directories since they can appear out of order anyway.
|
// We ignore directories since they can appear out of order anyway.
|
||||||
if (!entry.fileName.endsWith("/") && !ignoredFiles?.has(entry.fileName)) {
|
if (!entry.fileName.endsWith("/") && !ignoredFiles?.has(entry.fileName)) {
|
||||||
const destPath = join(outputPath, entry.fileName);
|
const destPath = join(outputPath, entry.fileName);
|
||||||
const fileContent = await readContent(zip, entry);
|
const fileContent = await readContent();
|
||||||
|
|
||||||
await fsExtra.mkdirs(dirname(destPath));
|
await fsExtra.mkdirs(dirname(destPath));
|
||||||
await fs.writeFile(destPath, fileContent);
|
await fs.writeFile(destPath, fileContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
zip.readEntry();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,9 +293,12 @@ export async function buildDocsFromConfig(configPath?: string, gitRootDir?: stri
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize the build environment before using cls
|
||||||
|
await initializeBuildEnvironment();
|
||||||
|
|
||||||
// Trigger the actual build.
|
// Trigger the actual build.
|
||||||
await new Promise((res, rej) => {
|
await new Promise((res, rej) => {
|
||||||
cls.init(() => {
|
getContext().init(() => {
|
||||||
buildDocsInner(config ?? undefined)
|
buildDocsInner(config ?? undefined)
|
||||||
.catch(rej)
|
.catch(rej)
|
||||||
.then(res);
|
.then(res);
|
||||||
@@ -263,9 +313,12 @@ export default async function buildDocs({ gitRootDir }: BuildContext) {
|
|||||||
cwd: gitRootDir
|
cwd: gitRootDir
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize the build environment before using cls
|
||||||
|
await initializeBuildEnvironment();
|
||||||
|
|
||||||
// Trigger the actual build.
|
// Trigger the actual build.
|
||||||
await new Promise((res, rej) => {
|
await new Promise((res, rej) => {
|
||||||
cls.init(() => {
|
getContext().init(() => {
|
||||||
buildDocsInner()
|
buildDocsInner()
|
||||||
.catch(rej)
|
.catch(rej)
|
||||||
.then(res);
|
.then(res);
|
||||||
|
|||||||
@@ -28,4 +28,13 @@ async function main() {
|
|||||||
cpSync(join(context.baseDir, "user-guide/404.html"), join(context.baseDir, "404.html"));
|
cpSync(join(context.baseDir, "user-guide/404.html"), join(context.baseDir, "404.html"));
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
// Note: forcing process.exit() because importing notes via the core triggers
|
||||||
|
// fire-and-forget async work in `notes.ts#downloadImages` (a 5s setTimeout that
|
||||||
|
// re-schedules itself via `asyncPostProcessContent`), which keeps the libuv
|
||||||
|
// event loop alive forever even after main() completes.
|
||||||
|
main()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error building documentation:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
"eslint.config.mjs"
|
"eslint.config.mjs"
|
||||||
],
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../../packages/commons/tsconfig.lib.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../packages/trilium-core/tsconfig.lib.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../server/tsconfig.app.json"
|
"path": "../server/tsconfig.app.json"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": [
|
"files": [],
|
||||||
"scripts/**/*.ts"
|
"include": [],
|
||||||
],
|
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"path": "../server"
|
"path": "../server"
|
||||||
|
|||||||
4
apps/client-standalone/.env
Normal file
4
apps/client-standalone/.env
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# The development license key for premium CKEditor features.
|
||||||
|
# Note: This key must only be used for the Trilium Notes project.
|
||||||
|
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
|
||||||
|
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||||
1
apps/client-standalone/.env.production
Normal file
1
apps/client-standalone/.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||||
94
apps/client-standalone/package.json
Normal file
94
apps/client-standalone/package.json
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"name": "@triliumnext/client-standalone",
|
||||||
|
"version": "0.102.2",
|
||||||
|
"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 vite preview --port 8888",
|
||||||
|
"coverage": "vitest --coverage",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"start-prod-no-dir": "pnpm build && pnpm vite preview --host 127.0.0.1"
|
||||||
|
},
|
||||||
|
"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.1",
|
||||||
|
"@mind-elixir/node-menu": "5.0.1",
|
||||||
|
"@popperjs/core": "2.11.8",
|
||||||
|
"@preact/signals": "2.9.0",
|
||||||
|
"@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.8.0",
|
||||||
|
"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",
|
||||||
|
"fflate": "0.8.2",
|
||||||
|
"force-graph": "1.51.2",
|
||||||
|
"globals": "17.4.0",
|
||||||
|
"i18next": "26.0.4",
|
||||||
|
"i18next-http-backend": "3.0.4",
|
||||||
|
"aes-js": "3.1.2",
|
||||||
|
"jquery": "4.0.0",
|
||||||
|
"jquery.fancytree": "2.38.5",
|
||||||
|
"js-md5": "0.8.3",
|
||||||
|
"js-sha1": "0.7.0",
|
||||||
|
"js-sha256": "0.11.1",
|
||||||
|
"js-sha512": "0.9.0",
|
||||||
|
"scrypt-js": "3.0.1",
|
||||||
|
"jsplumb": "2.15.6",
|
||||||
|
"katex": "0.16.45",
|
||||||
|
"knockout": "3.5.1",
|
||||||
|
"leaflet": "1.9.4",
|
||||||
|
"leaflet-gpx": "2.2.0",
|
||||||
|
"mark.js": "8.11.1",
|
||||||
|
"marked": "18.0.0",
|
||||||
|
"mermaid": "11.14.0",
|
||||||
|
"mind-elixir": "5.10.0",
|
||||||
|
"normalize.css": "8.0.1",
|
||||||
|
"panzoom": "9.4.4",
|
||||||
|
"preact": "10.29.1",
|
||||||
|
"react-i18next": "17.0.2",
|
||||||
|
"react-window": "2.2.7",
|
||||||
|
"reveal.js": "6.0.0",
|
||||||
|
"svg-pan-zoom": "3.6.2",
|
||||||
|
"tabulator-tables": "6.4.0",
|
||||||
|
"vanilla-js-wheel-zoom": "9.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/aes-js": "3.1.4",
|
||||||
|
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||||
|
"@preact/preset-vite": "2.10.2",
|
||||||
|
"@types/bootstrap": "5.2.10",
|
||||||
|
"@types/jquery": "4.0.0",
|
||||||
|
"@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": "14.0.0",
|
||||||
|
"cross-env": "7.0.3",
|
||||||
|
"happy-dom": "20.8.9",
|
||||||
|
"script-loader": "0.7.2",
|
||||||
|
"vite-plugin-static-copy": "4.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/client-standalone/playwright.config.ts
Normal file
20
apps/client-standalone/playwright.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { createBaseConfig } from "../../packages/trilium-e2e/src/base-config";
|
||||||
|
|
||||||
|
const port = process.env["TRILIUM_PORT"] ?? "8082";
|
||||||
|
const baseURL = process.env["BASE_URL"] || `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
export default createBaseConfig({
|
||||||
|
appDir: __dirname,
|
||||||
|
projectName: "standalone",
|
||||||
|
workers: 1,
|
||||||
|
webServer: !process.env.TRILIUM_DOCKER ? {
|
||||||
|
command: `pnpm build && pnpm vite preview --host 127.0.0.1 --port ${port}`,
|
||||||
|
url: baseURL,
|
||||||
|
env: {
|
||||||
|
TRILIUM_INTEGRATION_TEST: "memory"
|
||||||
|
},
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
cwd: __dirname,
|
||||||
|
timeout: 5 * 60 * 1000
|
||||||
|
} : undefined,
|
||||||
|
});
|
||||||
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
|
||||||
BIN
apps/client-standalone/public/favicon.ico
Normal file
BIN
apps/client-standalone/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
20
apps/client-standalone/public/manifest.webmanifest
Normal file
20
apps/client-standalone/public/manifest.webmanifest
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Trilium Notes",
|
||||||
|
"short_name": "Trilium",
|
||||||
|
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
|
||||||
|
"theme_color": "#333333",
|
||||||
|
"background_color": "#1F1F1F",
|
||||||
|
"display": "standalone",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"display_override": [
|
||||||
|
"window-controls-overlay"
|
||||||
|
],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "assets/icon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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";
|
||||||
31
apps/client-standalone/src/index.html
Normal file
31
apps/client-standalone/src/index.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!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>Trilium requires JavaScript to be enabled.</noscript>
|
||||||
|
|
||||||
|
<div id="context-menu-cover"></div>
|
||||||
|
<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>
|
||||||
156
apps/client-standalone/src/lightweight/backup_provider.ts
Normal file
156
apps/client-standalone/src/lightweight/backup_provider.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type { DatabaseBackup } from "@triliumnext/commons";
|
||||||
|
import { BackupOptionsService, BackupService, getSql } from "@triliumnext/core";
|
||||||
|
|
||||||
|
const BACKUP_DIR_NAME = "backups";
|
||||||
|
const BACKUP_FILE_PATTERN = /^backup-.*\.db$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone backup service using OPFS (Origin Private File System).
|
||||||
|
* Stores database backups as serialized byte arrays in OPFS.
|
||||||
|
* Falls back to no-op behavior when OPFS is not available (e.g., in tests).
|
||||||
|
*/
|
||||||
|
export default class StandaloneBackupService extends BackupService {
|
||||||
|
private backupDir: FileSystemDirectoryHandle | null = null;
|
||||||
|
private opfsAvailable: boolean | null = null;
|
||||||
|
|
||||||
|
constructor(options: BackupOptionsService) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOpfsAvailable(): boolean {
|
||||||
|
if (this.opfsAvailable === null) {
|
||||||
|
this.opfsAvailable = typeof navigator !== "undefined"
|
||||||
|
&& navigator.storage
|
||||||
|
&& typeof navigator.storage.getDirectory === "function";
|
||||||
|
}
|
||||||
|
return this.opfsAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBackupDirectory(): Promise<FileSystemDirectoryHandle | null> {
|
||||||
|
if (!this.isOpfsAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.backupDir) {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
this.backupDir = await root.getDirectoryHandle(BACKUP_DIR_NAME, { create: true });
|
||||||
|
}
|
||||||
|
return this.backupDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
override async backupNow(name: string): Promise<string> {
|
||||||
|
const fileName = `backup-${name}.db`;
|
||||||
|
|
||||||
|
// Check if OPFS is available
|
||||||
|
if (!this.isOpfsAvailable()) {
|
||||||
|
console.warn(`[Backup] OPFS not available, skipping backup: ${fileName}`);
|
||||||
|
return `/${BACKUP_DIR_NAME}/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dir = await this.ensureBackupDirectory();
|
||||||
|
if (!dir) {
|
||||||
|
console.warn(`[Backup] Backup directory not available, skipping: ${fileName}`);
|
||||||
|
return `/${BACKUP_DIR_NAME}/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the database
|
||||||
|
const data = getSql().serialize();
|
||||||
|
|
||||||
|
// Write to OPFS
|
||||||
|
const fileHandle = await dir.getFileHandle(fileName, { create: true });
|
||||||
|
const writable = await fileHandle.createWritable();
|
||||||
|
await writable.write(data);
|
||||||
|
await writable.close();
|
||||||
|
|
||||||
|
console.log(`[Backup] Created backup: ${fileName} (${data.byteLength} bytes)`);
|
||||||
|
return `/${BACKUP_DIR_NAME}/${fileName}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Backup] Failed to create backup ${fileName}:`, error);
|
||||||
|
// Don't throw - backup failure shouldn't block operations
|
||||||
|
return `/${BACKUP_DIR_NAME}/${fileName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getExistingBackups(): Promise<DatabaseBackup[]> {
|
||||||
|
if (!this.isOpfsAvailable()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dir = await this.ensureBackupDirectory();
|
||||||
|
if (!dir) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups: DatabaseBackup[] = [];
|
||||||
|
|
||||||
|
for await (const [name, handle] of dir.entries()) {
|
||||||
|
if (handle.kind !== "file" || !BACKUP_FILE_PATTERN.test(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await (handle as FileSystemFileHandle).getFile();
|
||||||
|
backups.push({
|
||||||
|
fileName: name,
|
||||||
|
filePath: `/${BACKUP_DIR_NAME}/${name}`,
|
||||||
|
mtime: new Date(file.lastModified)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by modification time, newest first
|
||||||
|
backups.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||||
|
return backups;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Backup] Failed to list backups:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a backup by filename.
|
||||||
|
*/
|
||||||
|
async deleteBackup(fileName: string): Promise<void> {
|
||||||
|
if (!this.isOpfsAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dir = await this.ensureBackupDirectory();
|
||||||
|
if (!dir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await dir.removeEntry(fileName);
|
||||||
|
console.log(`[Backup] Deleted backup: ${fileName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Backup] Failed to delete backup ${fileName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getBackupContent(filePath: string): Promise<Uint8Array | null> {
|
||||||
|
if (!this.isOpfsAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dir = await this.ensureBackupDirectory();
|
||||||
|
if (!dir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract fileName from filePath (e.g., "/backups/backup-now.db" -> "backup-now.db")
|
||||||
|
const fileName = filePath.split("/").pop();
|
||||||
|
if (!fileName || !BACKUP_FILE_PATTERN.test(fileName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileHandle = await dir.getFileHandle(fileName);
|
||||||
|
const file = await fileHandle.getFile();
|
||||||
|
const data = await file.arrayBuffer();
|
||||||
|
return new Uint8Array(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Backup] Failed to get backup content ${filePath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
314
apps/client-standalone/src/lightweight/browser_router.ts
Normal file
314
apps/client-standalone/src/lightweight/browser_router.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* 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 UploadedFile {
|
||||||
|
originalname: string;
|
||||||
|
mimetype: string;
|
||||||
|
buffer: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserRequest {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
path: string;
|
||||||
|
params: Record<string, string>;
|
||||||
|
query: Record<string, string | undefined>;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
file?: UploadedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol used to mark a result as an already-formatted response,
|
||||||
|
* so that formatResult passes it through without JSON-serializing.
|
||||||
|
* Must match the symbol exported from browser_routes.ts.
|
||||||
|
*/
|
||||||
|
const RAW_RESPONSE = Symbol.for('RAW_RESPONSE');
|
||||||
|
|
||||||
|
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 body based on content-type
|
||||||
|
let parsedBody = body;
|
||||||
|
let uploadedFile: UploadedFile | undefined;
|
||||||
|
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);
|
||||||
|
parsedBody = body;
|
||||||
|
}
|
||||||
|
} else if (contentType.includes('multipart/form-data')) {
|
||||||
|
try {
|
||||||
|
// Reconstruct a Response so we can use the native FormData parser
|
||||||
|
const response = new Response(body, { headers: { 'content-type': contentType } });
|
||||||
|
const formData = await response.formData();
|
||||||
|
const formFields: Record<string, string> = {};
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
formFields[key] = value;
|
||||||
|
} else {
|
||||||
|
// File field (Blob) — multer uses the field name "upload"
|
||||||
|
const fileBuffer = new Uint8Array(await value.arrayBuffer());
|
||||||
|
uploadedFile = {
|
||||||
|
originalname: value.name,
|
||||||
|
mimetype: value.type || 'application/octet-stream',
|
||||||
|
buffer: fileBuffer
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsedBody = formFields;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Router] Failed to parse multipart body:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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,
|
||||||
|
headers: headers ?? {},
|
||||||
|
body: parsedBody,
|
||||||
|
file: uploadedFile
|
||||||
|
};
|
||||||
|
|
||||||
|
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 raw responses (e.g. from image routes that write directly to res)
|
||||||
|
if (result && typeof result === 'object' && RAW_RESPONSE in result) {
|
||||||
|
const raw = result as unknown as { status: number; headers: Record<string, string>; body: unknown };
|
||||||
|
let body: ArrayBuffer | null = null;
|
||||||
|
|
||||||
|
if (raw.body instanceof ArrayBuffer) {
|
||||||
|
body = raw.body;
|
||||||
|
} else if (raw.body instanceof Uint8Array) {
|
||||||
|
body = raw.body.buffer as ArrayBuffer;
|
||||||
|
} else if (typeof raw.body === 'string') {
|
||||||
|
body = encoder.encode(raw.body).buffer as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: raw.status,
|
||||||
|
headers: raw.headers,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
340
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
340
apps/client-standalone/src/lightweight/browser_routes.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Browser route definitions.
|
||||||
|
* This integrates with the shared route builder from @triliumnext/core.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BootstrapDefinition } from '@triliumnext/commons';
|
||||||
|
import { entity_changes, getContext, getPlatform, getSharedBootstrapItems, getSql, routes, sql_init } from '@triliumnext/core';
|
||||||
|
|
||||||
|
import packageJson from '../../package.json' with { type: 'json' };
|
||||||
|
import { type BrowserRequest, BrowserRouter } from './browser_router';
|
||||||
|
|
||||||
|
/** Minimal response object used by apiResultHandler to capture the processed result. */
|
||||||
|
interface ResultHandlerResponse {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
result: unknown;
|
||||||
|
setHeader(name: string, value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol used to mark a result as an already-formatted BrowserResponse,
|
||||||
|
* so that BrowserRouter.formatResult passes it through without JSON-serializing.
|
||||||
|
* Uses Symbol.for() so the same symbol is shared across modules.
|
||||||
|
*/
|
||||||
|
const RAW_RESPONSE = Symbol.for('RAW_RESPONSE');
|
||||||
|
|
||||||
|
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Express-like request object from a BrowserRequest.
|
||||||
|
*/
|
||||||
|
function toExpressLikeReq(req: BrowserRequest) {
|
||||||
|
return {
|
||||||
|
params: req.params,
|
||||||
|
query: req.query,
|
||||||
|
body: req.body,
|
||||||
|
headers: req.headers ?? {},
|
||||||
|
method: req.method,
|
||||||
|
file: req.file,
|
||||||
|
get originalUrl() { return req.url; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts context headers from the request and sets them in the execution context,
|
||||||
|
* mirroring what the server does in route_api.ts.
|
||||||
|
*/
|
||||||
|
function setContextFromHeaders(req: BrowserRequest) {
|
||||||
|
const headers = req.headers ?? {};
|
||||||
|
const ctx = getContext();
|
||||||
|
ctx.set("componentId", headers["trilium-component-id"]);
|
||||||
|
ctx.set("localNowDateTime", headers["trilium-local-now-datetime"]);
|
||||||
|
ctx.set("hoistedNoteId", headers["trilium-hoisted-note-id"] || "root");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a core route handler to work with the BrowserRouter.
|
||||||
|
* Core handlers expect an Express-like request object with params, query, and body.
|
||||||
|
* Each request is wrapped in an execution context (like cls.init() on the server)
|
||||||
|
* to ensure entity change tracking works correctly.
|
||||||
|
*/
|
||||||
|
function wrapHandler(handler: (req: any) => unknown, transactional: boolean) {
|
||||||
|
return (req: BrowserRequest) => {
|
||||||
|
return getContext().init(() => {
|
||||||
|
setContextFromHeaders(req);
|
||||||
|
const expressLikeReq = toExpressLikeReq(req);
|
||||||
|
if (transactional) {
|
||||||
|
return getSql().transactional(() => handler(expressLikeReq));
|
||||||
|
}
|
||||||
|
return handler(expressLikeReq);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an apiRoute function compatible with buildSharedApiRoutes.
|
||||||
|
* This bridges the core's route registration to the BrowserRouter.
|
||||||
|
*/
|
||||||
|
function createApiRoute(router: BrowserRouter, transactional: boolean) {
|
||||||
|
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
|
||||||
|
router.register(method, path, wrapHandler(handler, transactional));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level route registration matching the server's `route()` signature:
|
||||||
|
* route(method, path, middleware[], handler, resultHandler)
|
||||||
|
*
|
||||||
|
* In standalone mode:
|
||||||
|
* - Middleware (e.g. checkApiAuth) is skipped — there's no authentication.
|
||||||
|
* - The resultHandler is applied to post-process the result (entity conversion, status codes).
|
||||||
|
*/
|
||||||
|
function createRoute(router: BrowserRouter) {
|
||||||
|
return (method: HttpMethod, path: string, _middleware: any[], handler: (req: any, res: any) => unknown, resultHandler?: ((req: any, res: any, result: unknown) => unknown) | null) => {
|
||||||
|
router.register(method, path, (req: BrowserRequest) => {
|
||||||
|
return getContext().init(() => {
|
||||||
|
setContextFromHeaders(req);
|
||||||
|
const expressLikeReq = toExpressLikeReq(req);
|
||||||
|
const mockRes = createMockExpressResponse();
|
||||||
|
const result = getSql().transactional(() => handler(expressLikeReq, mockRes));
|
||||||
|
|
||||||
|
// If the handler used the mock response (e.g. image routes that call res.send()),
|
||||||
|
// return it as a raw response so BrowserRouter doesn't JSON-serialize it.
|
||||||
|
if (mockRes._used) {
|
||||||
|
return {
|
||||||
|
[RAW_RESPONSE]: true as const,
|
||||||
|
status: mockRes._status,
|
||||||
|
headers: mockRes._headers,
|
||||||
|
body: mockRes._body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultHandler) {
|
||||||
|
// Create a minimal response object that captures what apiResultHandler sets.
|
||||||
|
const res = createResultHandlerResponse();
|
||||||
|
resultHandler(expressLikeReq, res, result);
|
||||||
|
return res.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async variant of createRoute for handlers that return Promises (e.g. import).
|
||||||
|
* Uses transactionalAsync (manual BEGIN/COMMIT/ROLLBACK) instead of the synchronous
|
||||||
|
* transactional() wrapper, which would commit an empty transaction immediately when
|
||||||
|
* passed an async callback.
|
||||||
|
*/
|
||||||
|
function createAsyncRoute(router: BrowserRouter) {
|
||||||
|
return (method: HttpMethod, path: string, _middleware: any[], handler: (req: any, res: any) => Promise<unknown>, resultHandler?: ((req: any, res: any, result: unknown) => unknown) | null) => {
|
||||||
|
router.register(method, path, (req: BrowserRequest) => {
|
||||||
|
return getContext().init(async () => {
|
||||||
|
setContextFromHeaders(req);
|
||||||
|
const expressLikeReq = toExpressLikeReq(req);
|
||||||
|
const mockRes = createMockExpressResponse();
|
||||||
|
const result = await getSql().transactionalAsync(() => handler(expressLikeReq, mockRes));
|
||||||
|
|
||||||
|
// If the handler used the mock response (e.g. image routes that call res.send()),
|
||||||
|
// return it as a raw response so BrowserRouter doesn't JSON-serialize it.
|
||||||
|
if (mockRes._used) {
|
||||||
|
return {
|
||||||
|
[RAW_RESPONSE]: true as const,
|
||||||
|
status: mockRes._status,
|
||||||
|
headers: mockRes._headers,
|
||||||
|
body: mockRes._body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultHandler) {
|
||||||
|
// Create a minimal response object that captures what apiResultHandler sets.
|
||||||
|
const res = createResultHandlerResponse();
|
||||||
|
resultHandler(expressLikeReq, res, result);
|
||||||
|
return res.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock Express response object that captures calls to set(), send(), sendStatus(), etc.
|
||||||
|
* Used for route handlers (like image routes) that write directly to the response.
|
||||||
|
*/
|
||||||
|
function createMockExpressResponse() {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const res = {
|
||||||
|
_used: false,
|
||||||
|
_status: 200,
|
||||||
|
_headers: {} as Record<string, string>,
|
||||||
|
_body: null as unknown,
|
||||||
|
set(name: string, value: string) {
|
||||||
|
res._headers[name] = value;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
setHeader(name: string, value: string) {
|
||||||
|
res._headers[name] = value;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
removeHeader(name: string) {
|
||||||
|
delete res._headers[name];
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
status(code: number) {
|
||||||
|
res._status = code;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
send(body: unknown) {
|
||||||
|
res._used = true;
|
||||||
|
res._body = body;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
sendStatus(code: number) {
|
||||||
|
res._used = true;
|
||||||
|
res._status = code;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
write(chunk: string) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
end() {
|
||||||
|
res._used = true;
|
||||||
|
res._body = chunks.join("");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone apiResultHandler matching the server's behavior:
|
||||||
|
* - Converts Becca entities to POJOs
|
||||||
|
* - Handles [statusCode, response] tuple format
|
||||||
|
* - Sets trilium-max-entity-change-id (captured in response headers)
|
||||||
|
*/
|
||||||
|
function apiResultHandler(_req: any, res: ResultHandlerResponse, result: unknown) {
|
||||||
|
res.headers["trilium-max-entity-change-id"] = String(entity_changes.getMaxEntityChangeId());
|
||||||
|
result = routes.convertEntitiesToPojo(result);
|
||||||
|
|
||||||
|
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
|
||||||
|
const [_statusCode, response] = result;
|
||||||
|
res.result = response;
|
||||||
|
} else if (result === undefined) {
|
||||||
|
res.result = "";
|
||||||
|
} else {
|
||||||
|
res.result = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op middleware stubs for standalone mode.
|
||||||
|
*
|
||||||
|
* In a browser context there is no network authentication, rate limiting,
|
||||||
|
* or multi-user access, so all auth/rate-limit middleware is a no-op.
|
||||||
|
*
|
||||||
|
* `checkAppNotInitialized` still guards setup routes: if the database is
|
||||||
|
* already initialised the middleware throws so the route handler is never
|
||||||
|
* reached (mirrors the server behaviour).
|
||||||
|
*/
|
||||||
|
function noopMiddleware() {
|
||||||
|
// No-op.
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAppNotInitialized() {
|
||||||
|
if (sql_init.isDbInitialized()) {
|
||||||
|
throw new Error("App already initialized.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal response-like object for the apiResultHandler.
|
||||||
|
*/
|
||||||
|
function createResultHandlerResponse(): ResultHandlerResponse {
|
||||||
|
return {
|
||||||
|
headers: {},
|
||||||
|
result: undefined,
|
||||||
|
setHeader(name: string, value: string) {
|
||||||
|
this.headers[name] = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, true);
|
||||||
|
routes.buildSharedApiRoutes({
|
||||||
|
route: createRoute(router),
|
||||||
|
asyncRoute: createAsyncRoute(router),
|
||||||
|
apiRoute,
|
||||||
|
asyncApiRoute: createApiRoute(router, false),
|
||||||
|
apiResultHandler,
|
||||||
|
checkApiAuth: noopMiddleware,
|
||||||
|
checkApiAuthOrElectron: noopMiddleware,
|
||||||
|
checkAppNotInitialized,
|
||||||
|
checkCredentials: noopMiddleware,
|
||||||
|
loginRateLimiter: noopMiddleware,
|
||||||
|
uploadMiddlewareWithErrorHandling: noopMiddleware,
|
||||||
|
csrfMiddleware: noopMiddleware
|
||||||
|
});
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootstrapRoute(): BootstrapDefinition {
|
||||||
|
const assetPath = ".";
|
||||||
|
|
||||||
|
const isDbInitialized = sql_init.isDbInitialized();
|
||||||
|
const commonItems = {
|
||||||
|
...getSharedBootstrapItems(assetPath, isDbInitialized),
|
||||||
|
isDev: import.meta.env.DEV,
|
||||||
|
isStandalone: true,
|
||||||
|
isMainWindow: true,
|
||||||
|
isElectron: false,
|
||||||
|
hasNativeTitleBar: false,
|
||||||
|
hasBackgroundEffects: false,
|
||||||
|
triliumVersion: packageJson.version,
|
||||||
|
device: false as const, // Let the client detect device type.
|
||||||
|
appPath: assetPath,
|
||||||
|
instanceName: "standalone",
|
||||||
|
TRILIUM_SAFE_MODE: !!getPlatform().getEnv("TRILIUM_SAFE_MODE")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDbInitialized) {
|
||||||
|
return {
|
||||||
|
...commonItems,
|
||||||
|
baseApiUrl: "../api/",
|
||||||
|
isProtectedSessionAvailable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonItems,
|
||||||
|
csrfToken: "dummy-csrf-token",
|
||||||
|
baseApiUrl: "../api/",
|
||||||
|
headingStyle: "plain",
|
||||||
|
layoutOrientation: "vertical",
|
||||||
|
platform: "web",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and configure a router with all routes registered.
|
||||||
|
*/
|
||||||
|
export function createConfiguredRouter(): BrowserRouter {
|
||||||
|
const router = new BrowserRouter();
|
||||||
|
registerRoutes(router);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
77
apps/client-standalone/src/lightweight/cls_provider.ts
Normal file
77
apps/client-standalone/src/lightweight/cls_provider.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { ExecutionContext } from "@triliumnext/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser execution context implementation.
|
||||||
|
*
|
||||||
|
* Handles per-request context isolation with support for fire-and-forget async operations
|
||||||
|
* using a context stack and grace-period cleanup to allow unawaited promises to complete.
|
||||||
|
*/
|
||||||
|
export default class BrowserExecutionContext implements ExecutionContext {
|
||||||
|
private contextStack: Map<string, any>[] = [];
|
||||||
|
private cleanupTimers = new WeakMap<Map<string, any>, ReturnType<typeof setTimeout>>();
|
||||||
|
private readonly CLEANUP_GRACE_PERIOD = 1000; // 1 second for fire-and-forget operations
|
||||||
|
|
||||||
|
private getCurrentContext(): Map<string, any> {
|
||||||
|
if (this.contextStack.length === 0) {
|
||||||
|
throw new Error("ExecutionContext not initialized");
|
||||||
|
}
|
||||||
|
return this.contextStack[this.contextStack.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T = any>(key: string): T {
|
||||||
|
return this.getCurrentContext().get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: any): void {
|
||||||
|
this.getCurrentContext().set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.contextStack = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
init<T>(callback: () => T): T {
|
||||||
|
const context = new Map<string, any>();
|
||||||
|
this.contextStack.push(context);
|
||||||
|
|
||||||
|
// Cancel any pending cleanup timer for this context
|
||||||
|
const existingTimer = this.cleanupTimers.get(context);
|
||||||
|
if (existingTimer) {
|
||||||
|
clearTimeout(existingTimer);
|
||||||
|
this.cleanupTimers.delete(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = callback();
|
||||||
|
|
||||||
|
// If the result is a Promise
|
||||||
|
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
|
||||||
|
const promise = result as unknown as Promise<any>;
|
||||||
|
return promise.finally(() => {
|
||||||
|
this.scheduleContextCleanup(context);
|
||||||
|
}) as T;
|
||||||
|
} else {
|
||||||
|
// For synchronous results, schedule delayed cleanup to allow fire-and-forget operations
|
||||||
|
this.scheduleContextCleanup(context);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Always clean up on error with grace period
|
||||||
|
this.scheduleContextCleanup(context);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleContextCleanup(context: Map<string, any>): void {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// Remove from stack if still present
|
||||||
|
const index = this.contextStack.indexOf(context);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.contextStack.splice(index, 1);
|
||||||
|
}
|
||||||
|
this.cleanupTimers.delete(context);
|
||||||
|
}, this.CLEANUP_GRACE_PERIOD);
|
||||||
|
|
||||||
|
this.cleanupTimers.set(context, timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
apps/client-standalone/src/lightweight/crypto_provider.ts
Normal file
175
apps/client-standalone/src/lightweight/crypto_provider.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Cipher, CryptoProvider, ScryptOptions } from "@triliumnext/core";
|
||||||
|
import { binary_utils } from "@triliumnext/core";
|
||||||
|
import { sha1 } from "js-sha1";
|
||||||
|
import { sha256 } from "js-sha256";
|
||||||
|
import { sha512 } from "js-sha512";
|
||||||
|
import { md5 } from "js-md5";
|
||||||
|
import { scrypt } from "scrypt-js";
|
||||||
|
import aesjs from "aes-js";
|
||||||
|
|
||||||
|
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crypto provider for browser environments using pure JavaScript crypto libraries.
|
||||||
|
* Uses aes-js for synchronous AES encryption (matching Node.js behavior).
|
||||||
|
*/
|
||||||
|
export default class BrowserCryptoProvider implements CryptoProvider {
|
||||||
|
|
||||||
|
createHash(algorithm: "md5" | "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
|
||||||
|
const data = binary_utils.unwrapStringOrBuffer(content);
|
||||||
|
|
||||||
|
let hexHash: string;
|
||||||
|
if (algorithm === "md5") {
|
||||||
|
hexHash = md5(data);
|
||||||
|
} else if (algorithm === "sha1") {
|
||||||
|
hexHash = sha1(data);
|
||||||
|
} else {
|
||||||
|
hexHash = 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 {
|
||||||
|
return new AesJsCipher(algorithm, key, iv, "encrypt");
|
||||||
|
}
|
||||||
|
|
||||||
|
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
|
||||||
|
return new AesJsCipher(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
hmac(secret: string | Uint8Array, value: string | Uint8Array): string {
|
||||||
|
const secretStr = binary_utils.unwrapStringOrBuffer(secret);
|
||||||
|
const valueStr = binary_utils.unwrapStringOrBuffer(value);
|
||||||
|
// sha256.hmac returns hex, convert to base64 to match Node's behavior
|
||||||
|
const hexHash = sha256.hmac(secretStr, valueStr);
|
||||||
|
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 btoa(String.fromCharCode(...bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
async scrypt(
|
||||||
|
password: Uint8Array | string,
|
||||||
|
salt: Uint8Array | string,
|
||||||
|
keyLength: number,
|
||||||
|
options: ScryptOptions = {}
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const { N = 16384, r = 8, p = 1 } = options;
|
||||||
|
const passwordBytes = binary_utils.wrapStringOrBuffer(password);
|
||||||
|
const saltBytes = binary_utils.wrapStringOrBuffer(salt);
|
||||||
|
|
||||||
|
return scrypt(passwordBytes, saltBytes, N, r, p, keyLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
result |= a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
return result === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A synchronous cipher implementation using aes-js.
|
||||||
|
* Matches Node.js crypto behavior with update() and final() methods.
|
||||||
|
*/
|
||||||
|
class AesJsCipher implements Cipher {
|
||||||
|
private chunks: Uint8Array[] = [];
|
||||||
|
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.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 - we process everything in final() to match streaming behavior
|
||||||
|
this.chunks.push(data);
|
||||||
|
// Return empty array since aes-js CBC doesn't support true streaming
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final(): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode === "encrypt") {
|
||||||
|
// PKCS7 padding for encryption
|
||||||
|
const blockSize = 16;
|
||||||
|
const paddingLength = blockSize - (data.length % blockSize);
|
||||||
|
const paddedData = new Uint8Array(data.length + paddingLength);
|
||||||
|
paddedData.set(data);
|
||||||
|
paddedData.fill(paddingLength, data.length);
|
||||||
|
|
||||||
|
const aesCbc = new aesjs.ModeOfOperation.cbc(
|
||||||
|
Array.from(this.key),
|
||||||
|
Array.from(this.iv)
|
||||||
|
);
|
||||||
|
return new Uint8Array(aesCbc.encrypt(paddedData));
|
||||||
|
} else {
|
||||||
|
// Decryption
|
||||||
|
const aesCbc = new aesjs.ModeOfOperation.cbc(
|
||||||
|
Array.from(this.key),
|
||||||
|
Array.from(this.iv)
|
||||||
|
);
|
||||||
|
const decrypted = new Uint8Array(aesCbc.decrypt(data));
|
||||||
|
|
||||||
|
// Remove PKCS7 padding
|
||||||
|
const paddingLength = decrypted[decrypted.length - 1];
|
||||||
|
if (paddingLength > 0 && paddingLength <= 16) {
|
||||||
|
return decrypted.slice(0, decrypted.length - paddingLength);
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
apps/client-standalone/src/lightweight/log_provider.ts
Normal file
168
apps/client-standalone/src/lightweight/log_provider.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { FileBasedLogService, type LogFileInfo } from "@triliumnext/core";
|
||||||
|
|
||||||
|
const LOG_DIR_NAME = "logs";
|
||||||
|
const LOG_FILE_PATTERN = /^trilium-\d{4}-\d{2}-\d{2}\.log$/;
|
||||||
|
const DEFAULT_RETENTION_DAYS = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone log service using OPFS (Origin Private File System).
|
||||||
|
* Uses synchronous access handles available in service worker context.
|
||||||
|
*/
|
||||||
|
export default class StandaloneLogService extends FileBasedLogService {
|
||||||
|
private logDir: FileSystemDirectoryHandle | null = null;
|
||||||
|
private currentFile: FileSystemSyncAccessHandle | null = null;
|
||||||
|
private currentFileName: string = "";
|
||||||
|
private textEncoder = new TextEncoder();
|
||||||
|
private textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Abstract Method Implementations ====================
|
||||||
|
|
||||||
|
protected override get eol(): string {
|
||||||
|
return "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async ensureLogDirectory(): Promise<void> {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
this.logDir = await root.getDirectoryHandle(LOG_DIR_NAME, { create: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async openLogFile(fileName: string): Promise<void> {
|
||||||
|
if (!this.logDir) {
|
||||||
|
await this.ensureLogDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close existing file if open
|
||||||
|
if (this.currentFile) {
|
||||||
|
this.currentFile.close();
|
||||||
|
this.currentFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileHandle = await this.logDir!.getFileHandle(fileName, { create: true });
|
||||||
|
|
||||||
|
// Try to create sync access handle with retry logic for worker restarts
|
||||||
|
// Previous worker may have left handle open before being terminated
|
||||||
|
const maxRetries = 3;
|
||||||
|
const retryDelay = 100;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
this.currentFile = await fileHandle.createSyncAccessHandle();
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === maxRetries - 1) {
|
||||||
|
// Last attempt failed - fall back to console-only logging
|
||||||
|
console.warn("[LogService] Could not open log file, using console-only logging:", error);
|
||||||
|
this.currentFile = null;
|
||||||
|
this.currentFileName = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Wait before retrying - previous handle may be released
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentFileName = fileName;
|
||||||
|
|
||||||
|
// Seek to end for appending
|
||||||
|
if (this.currentFile) {
|
||||||
|
const size = this.currentFile.getSize();
|
||||||
|
this.currentFile.truncate(size); // No-op, but ensures we're at the right position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override closeLogFile(): void {
|
||||||
|
if (this.currentFile) {
|
||||||
|
this.currentFile.close();
|
||||||
|
this.currentFile = null;
|
||||||
|
this.currentFileName = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override writeEntry(entry: string): void {
|
||||||
|
if (!this.currentFile) {
|
||||||
|
console.log(entry); // Fallback to console if file not ready
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = this.textEncoder.encode(entry);
|
||||||
|
const currentSize = this.currentFile.getSize();
|
||||||
|
this.currentFile.write(data, { at: currentSize });
|
||||||
|
this.currentFile.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override readLogFile(fileName: string): string | null {
|
||||||
|
if (!this.logDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For the current file, we need to read from the sync handle
|
||||||
|
if (fileName === this.currentFileName && this.currentFile) {
|
||||||
|
const size = this.currentFile.getSize();
|
||||||
|
const buffer = new ArrayBuffer(size);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
this.currentFile.read(view, { at: 0 });
|
||||||
|
return this.textDecoder.decode(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other files, we'd need async access - return null for now
|
||||||
|
// The current file is what's most commonly needed
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async listLogFiles(): Promise<LogFileInfo[]> {
|
||||||
|
if (!this.logDir) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const logFiles: LogFileInfo[] = [];
|
||||||
|
|
||||||
|
for await (const [name, handle] of this.logDir.entries()) {
|
||||||
|
if (handle.kind !== "file" || !LOG_FILE_PATTERN.test(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPFS doesn't provide mtime directly, so we parse from filename
|
||||||
|
const match = name.match(/trilium-(\d{4})-(\d{2})-(\d{2})\.log/);
|
||||||
|
if (match) {
|
||||||
|
const mtime = new Date(
|
||||||
|
parseInt(match[1]),
|
||||||
|
parseInt(match[2]) - 1,
|
||||||
|
parseInt(match[3])
|
||||||
|
);
|
||||||
|
logFiles.push({ name, mtime });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return logFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async deleteLogFile(fileName: string): Promise<void> {
|
||||||
|
if (!this.logDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't delete the current file
|
||||||
|
if (fileName === this.currentFileName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.logDir.removeEntry(fileName);
|
||||||
|
} catch {
|
||||||
|
// File might not exist or be locked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getRetentionDays(): number {
|
||||||
|
// Standalone doesn't have config system, use default
|
||||||
|
return DEFAULT_RETENTION_DAYS;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
120
apps/client-standalone/src/lightweight/messaging_provider.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { WebSocketMessage } from "@triliumnext/commons";
|
||||||
|
import type { ClientMessageHandler, MessageHandler,MessagingProvider } 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 clientMessageHandler?: ClientMessageHandler;
|
||||||
|
private isDisposed = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Listen for incoming messages from the main thread
|
||||||
|
self.addEventListener("message", this.handleIncomingMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIncomingMessage = (event: MessageEvent) => {
|
||||||
|
if (this.isDisposed) return;
|
||||||
|
|
||||||
|
const { type, message } = event.data || {};
|
||||||
|
|
||||||
|
if (type === "WS_MESSAGE" && message) {
|
||||||
|
// Dispatch to the client message handler (used by ws.ts for log-error, log-info, ping)
|
||||||
|
if (this.clientMessageHandler) {
|
||||||
|
try {
|
||||||
|
this.clientMessageHandler("main-thread", message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[WorkerMessagingProvider] Error in client message handler:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a specific client.
|
||||||
|
* In worker context, there's only one client (the main thread), so clientId is ignored.
|
||||||
|
*/
|
||||||
|
sendMessageToClient(_clientId: string, message: WebSocketMessage): boolean {
|
||||||
|
if (this.isDisposed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendMessageToAllClients(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a handler for incoming client messages.
|
||||||
|
*/
|
||||||
|
setClientMessageHandler(handler: ClientMessageHandler): void {
|
||||||
|
this.clientMessageHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/client-standalone/src/lightweight/platform_provider.ts
Normal file
42
apps/client-standalone/src/lightweight/platform_provider.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { PlatformProvider } from "@triliumnext/core";
|
||||||
|
|
||||||
|
// Build-time constant injected by Vite (see `define` in vite.config.mts).
|
||||||
|
declare const __TRILIUM_INTEGRATION_TEST__: string;
|
||||||
|
|
||||||
|
/** Maps URL query parameter names to TRILIUM_ environment variable names. */
|
||||||
|
const QUERY_TO_ENV: Record<string, string> = {
|
||||||
|
"safeMode": "TRILIUM_SAFE_MODE",
|
||||||
|
"startNoteId": "TRILIUM_START_NOTE_ID",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class StandalonePlatformProvider implements PlatformProvider {
|
||||||
|
readonly isElectron = false;
|
||||||
|
readonly isMac = false;
|
||||||
|
readonly isWindows = false;
|
||||||
|
|
||||||
|
private envMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
constructor(queryString: string) {
|
||||||
|
const params = new URLSearchParams(queryString);
|
||||||
|
for (const [queryKey, envKey] of Object.entries(QUERY_TO_ENV)) {
|
||||||
|
if (params.has(queryKey)) {
|
||||||
|
this.envMap[envKey] = params.get(queryKey) || "true";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (__TRILIUM_INTEGRATION_TEST__) {
|
||||||
|
this.envMap["TRILIUM_INTEGRATION_TEST"] = __TRILIUM_INTEGRATION_TEST__;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crash(message: string): void {
|
||||||
|
console.error("[Standalone] FATAL:", message);
|
||||||
|
self.postMessage({
|
||||||
|
type: "FATAL_ERROR",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnv(key: string): string | undefined {
|
||||||
|
return this.envMap[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
93
apps/client-standalone/src/lightweight/request_provider.ts
Normal file
93
apps/client-standalone/src/lightweight/request_provider.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ExecOpts, RequestProvider } from "@triliumnext/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch-based implementation of RequestProvider for browser environments.
|
||||||
|
*
|
||||||
|
* Uses the Fetch API instead of Node's http/https modules.
|
||||||
|
* Proxy support is not available in browsers, so the proxy option is ignored.
|
||||||
|
*/
|
||||||
|
export default class FetchRequestProvider implements RequestProvider {
|
||||||
|
|
||||||
|
async exec<T>(opts: ExecOpts): Promise<T> {
|
||||||
|
const paging = opts.paging || {
|
||||||
|
pageCount: 1,
|
||||||
|
pageIndex: 0,
|
||||||
|
requestId: "n/a"
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": paging.pageCount === 1 ? "application/json" : "text/plain",
|
||||||
|
"pageCount": String(paging.pageCount),
|
||||||
|
"pageIndex": String(paging.pageIndex),
|
||||||
|
"requestId": paging.requestId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: the Cookie header is a forbidden header in fetch —
|
||||||
|
// the browser manages cookies automatically via credentials: 'include'.
|
||||||
|
|
||||||
|
if (opts.auth?.password) {
|
||||||
|
headers["trilium-cred"] = btoa(`dummy:${opts.auth.password}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: string | undefined;
|
||||||
|
if (opts.body) {
|
||||||
|
body = typeof opts.body === "object" ? JSON.stringify(opts.body) : opts.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = opts.timeout
|
||||||
|
? setTimeout(() => controller.abort(), opts.timeout)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(opts.url, {
|
||||||
|
method: opts.method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
|
||||||
|
if ([200, 201, 204].includes(response.status)) {
|
||||||
|
const text = await response.text();
|
||||||
|
return text.trim() ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
let errorMessage: string;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
errorMessage = json?.message || "";
|
||||||
|
} catch {
|
||||||
|
errorMessage = text.substring(0, 100);
|
||||||
|
}
|
||||||
|
throw new Error(`${response.status} ${opts.method} ${opts.url}: ${errorMessage}`);
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === "AbortError") {
|
||||||
|
throw new Error(`${opts.method} ${opts.url} failed, error: timeout after ${opts.timeout}ms`);
|
||||||
|
}
|
||||||
|
if (e instanceof TypeError && e.message === "Failed to fetch") {
|
||||||
|
const isCrossOrigin = !opts.url.startsWith(location.origin);
|
||||||
|
if (isCrossOrigin) {
|
||||||
|
throw new Error(`Request to ${opts.url} was blocked. The server may not allow requests from this origin (CORS), or it may be unreachable.`);
|
||||||
|
}
|
||||||
|
throw new Error(`Request to ${opts.url} failed. The server may be unreachable.`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getImage(imageUrl: string): Promise<ArrayBuffer> {
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} GET ${imageUrl} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.arrayBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
742
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
742
apps/client-standalone/src/lightweight/sql_provider.ts
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
import { type BindableValue, type SAHPoolUtil, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm";
|
||||||
|
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
private sqlite3: Sqlite3Module,
|
||||||
|
private sql: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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();
|
||||||
|
// Get the last insert row ID using the C API
|
||||||
|
const lastInsertRowid = this.db.pointer ? this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer) : 0;
|
||||||
|
this.stmt.reset();
|
||||||
|
return {
|
||||||
|
changes,
|
||||||
|
lastInsertRowid: typeof lastInsertRowid === "bigint" ? Number(lastInsertRowid) : lastInsertRowid
|
||||||
|
};
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the prefix used for a parameter name in the SQL query.
|
||||||
|
* SQLite supports @name, :name, and $name parameter styles.
|
||||||
|
* Returns the prefix character, or ':' as default if not found.
|
||||||
|
*/
|
||||||
|
private detectParamPrefix(paramName: string): string {
|
||||||
|
// Search for the parameter with each possible prefix
|
||||||
|
for (const prefix of [':', '@', '$']) {
|
||||||
|
// Use word boundary to avoid partial matches
|
||||||
|
const pattern = new RegExp(`\\${prefix}${paramName}(?![a-zA-Z0-9_])`);
|
||||||
|
if (pattern.test(this.sql)) {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default to ':' if not found (most common in Trilium)
|
||||||
|
return ':';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 $)
|
||||||
|
// We detect the prefix used in the SQL for each parameter
|
||||||
|
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 {
|
||||||
|
// Detect the prefix used in the SQL and apply it
|
||||||
|
const prefix = this.detectParamPrefix(key);
|
||||||
|
bindings[`${prefix}${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;
|
||||||
|
|
||||||
|
// SAHPool state tracking
|
||||||
|
private sahPoolUtil?: SAHPoolUtil;
|
||||||
|
private sahPoolDbName?: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SAHPool VFS (preferred OPFS backend) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the OPFS SAHPool VFS. This pre-allocates a pool of OPFS
|
||||||
|
* SyncAccessHandle objects, enabling WAL mode and significantly faster
|
||||||
|
* writes compared to the legacy OPFS VFS.
|
||||||
|
*
|
||||||
|
* Must be called after `initWasm()` and before `loadFromSahPool()`.
|
||||||
|
* This is async because it acquires OPFS file handles.
|
||||||
|
*
|
||||||
|
* Unlike the legacy OPFS VFS, SAHPool does **not** require SharedArrayBuffer
|
||||||
|
* or COOP/COEP headers — it only needs OPFS itself (a Worker context with
|
||||||
|
* `navigator.storage.getDirectory`). This makes it usable in Capacitor's
|
||||||
|
* Android WebView, which doesn't support cross-origin isolation.
|
||||||
|
*
|
||||||
|
* @param options.directory - OPFS directory for the pool (default: auto-derived from VFS name)
|
||||||
|
* @param options.initialCapacity - Minimum number of file slots (default: 6)
|
||||||
|
* @throws Error if the environment doesn't support OPFS (no Worker, or no OPFS API)
|
||||||
|
*/
|
||||||
|
async installSahPool(options: { directory?: string; initialCapacity?: number } = {}): Promise<void> {
|
||||||
|
this.ensureSqlite3();
|
||||||
|
|
||||||
|
console.log("[BrowserSqlProvider] Installing OPFS SAHPool VFS...");
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
this.sahPoolUtil = await this.sqlite3!.installOpfsSAHPoolVfs({
|
||||||
|
clearOnInit: false,
|
||||||
|
initialCapacity: options.initialCapacity ?? 6,
|
||||||
|
directory: options.directory,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure enough slots for DB + WAL + journal + temp files
|
||||||
|
await this.sahPoolUtil.reserveMinimumCapacity(options.initialCapacity ?? 6);
|
||||||
|
|
||||||
|
const initTime = performance.now() - startTime;
|
||||||
|
console.log(
|
||||||
|
`[BrowserSqlProvider] SAHPool VFS installed in ${initTime.toFixed(2)}ms ` +
|
||||||
|
`(capacity: ${this.sahPoolUtil.getCapacity()}, files: ${this.sahPoolUtil.getFileCount()})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the SAHPool VFS has been successfully installed.
|
||||||
|
*/
|
||||||
|
get isSahPoolInstalled(): boolean {
|
||||||
|
return this.sahPoolUtil !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access the SAHPool utility for advanced operations (import/export/migration).
|
||||||
|
*/
|
||||||
|
get sahPool(): SAHPoolUtil | undefined {
|
||||||
|
return this.sahPoolUtil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load or create a database using the SAHPool VFS.
|
||||||
|
* This is the preferred method for persistent storage — it supports WAL mode
|
||||||
|
* and is significantly faster than the legacy OPFS VFS.
|
||||||
|
*
|
||||||
|
* @param dbName - Virtual filename within the pool (e.g., "/trilium.db").
|
||||||
|
* Must start with a slash.
|
||||||
|
* @throws Error if SAHPool VFS is not installed
|
||||||
|
*/
|
||||||
|
loadFromSahPool(dbName: string): void {
|
||||||
|
this.ensureSqlite3();
|
||||||
|
if (!this.sahPoolUtil) {
|
||||||
|
throw new Error(
|
||||||
|
"SAHPool VFS not installed. Call installSahPool() first."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BrowserSqlProvider] Loading database from SAHPool: ${dbName}`);
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db = new this.sahPoolUtil.OpfsSAHPoolDb(dbName);
|
||||||
|
this.sahPoolDbName = dbName;
|
||||||
|
this.opfsDbPath = undefined;
|
||||||
|
|
||||||
|
// SAHPool supports WAL mode — the key advantage over legacy OPFS VFS
|
||||||
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||||
|
|
||||||
|
const loadTime = performance.now() - startTime;
|
||||||
|
console.log(`[BrowserSqlProvider] SAHPool database loaded in ${loadTime.toFixed(2)}ms (WAL mode)`);
|
||||||
|
} catch (e) {
|
||||||
|
const error = e instanceof Error ? e : new Error(String(e));
|
||||||
|
console.error(`[BrowserSqlProvider] Failed to load SAHPool database: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the currently open database is using the SAHPool VFS.
|
||||||
|
*/
|
||||||
|
get isUsingSahPool(): boolean {
|
||||||
|
return this.sahPoolDbName !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Legacy OPFS Support ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the legacy 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 legacy 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.
|
||||||
|
*
|
||||||
|
* **Prefer `loadFromSahPool()` over this method** — it supports WAL mode
|
||||||
|
* and is significantly faster. This method is kept for migration purposes.
|
||||||
|
* 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;
|
||||||
|
this.sahPoolDbName = undefined;
|
||||||
|
|
||||||
|
// Configure the database for legacy OPFS
|
||||||
|
// Note: WAL mode is not supported by the legacy OPFS VFS
|
||||||
|
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 (legacy or SAHPool).
|
||||||
|
*/
|
||||||
|
get isUsingOpfs(): boolean {
|
||||||
|
return this.opfsDbPath !== undefined || this.sahPoolDbName !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OPFS path of the currently open database.
|
||||||
|
* Returns undefined if not using OPFS.
|
||||||
|
*/
|
||||||
|
get currentOpfsPath(): string | undefined {
|
||||||
|
return this.opfsDbPath ?? this.sahPoolDbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 SAHPool or OPFS for persistent storage.
|
||||||
|
throw new Error(
|
||||||
|
"loadFromFile is not supported in browser environment. " +
|
||||||
|
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
|
||||||
|
"loadFromSahPool() (preferred) 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;
|
||||||
|
this.sahPoolDbName = undefined;
|
||||||
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
|
||||||
|
const loadTime = performance.now() - startTime;
|
||||||
|
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFromBuffer(buffer: Uint8Array): void {
|
||||||
|
this.ensureSqlite3();
|
||||||
|
// SQLite WASM's allocFromTypedArray rejects Node's Buffer (and other
|
||||||
|
// non-Uint8Array typed arrays) with "expecting 8/16/32/64". Normalize
|
||||||
|
// to a plain Uint8Array view over the same memory so callers can pass
|
||||||
|
// anything readFileSync returns.
|
||||||
|
const view = buffer instanceof Uint8Array && buffer.constructor === Uint8Array
|
||||||
|
? buffer
|
||||||
|
: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||||
|
const p = this.sqlite3!.wasm.allocFromTypedArray(view);
|
||||||
|
try {
|
||||||
|
// Cached statements reference the previous DB and become invalid
|
||||||
|
// once we swap connections. Drop them so callers re-prepare.
|
||||||
|
this.clearStatementCache();
|
||||||
|
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
|
||||||
|
this.opfsDbPath = undefined;
|
||||||
|
this.sahPoolDbName = undefined;
|
||||||
|
|
||||||
|
const rc = this.sqlite3!.capi.sqlite3_deserialize(
|
||||||
|
this.db.pointer!,
|
||||||
|
"main",
|
||||||
|
p,
|
||||||
|
view.byteLength,
|
||||||
|
view.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.sqlite3!, query);
|
||||||
|
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 (either tracked via JS flag or via actual SQLite
|
||||||
|
// autocommit state), use SAVEPOINTs for nesting — this handles the case where a manual
|
||||||
|
// BEGIN was issued directly (e.g. transactionalAsync) without going through transaction().
|
||||||
|
const sqliteInTransaction = self.db?.pointer !== undefined
|
||||||
|
&& (self.sqlite3!.capi as any).sqlite3_get_autocommit(self.db!.pointer) === 0;
|
||||||
|
if (self._inTransaction || sqliteInTransaction) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearStatementCache(): void {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.clearStatementCache();
|
||||||
|
|
||||||
|
if (this.db) {
|
||||||
|
this.db.close();
|
||||||
|
this.db = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset OPFS / SAHPool state
|
||||||
|
this.opfsDbPath = undefined;
|
||||||
|
this.sahPoolDbName = 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(), " +
|
||||||
|
"loadFromSahPool(), or loadFromOpfs() first."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { LOCALE_IDS } from "@triliumnext/commons";
|
||||||
|
import type i18next from "i18next";
|
||||||
|
import I18NextHttpBackend from "i18next-http-backend";
|
||||||
|
|
||||||
|
export default async function translationProvider(i18nextInstance: typeof i18next, locale: LOCALE_IDS) {
|
||||||
|
await i18nextInstance.use(I18NextHttpBackend).init({
|
||||||
|
lng: locale,
|
||||||
|
fallbackLng: "en",
|
||||||
|
ns: "server",
|
||||||
|
backend: {
|
||||||
|
loadPath: `${import.meta.resolve("../server-assets/translations")}/{{lng}}/{{ns}}.json`
|
||||||
|
},
|
||||||
|
returnEmptyString: false,
|
||||||
|
debug: true
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { type ExportFormat, type ZipExportProviderData, ZipExportProvider } from "@triliumnext/core";
|
||||||
|
|
||||||
|
import contentCss from "@triliumnext/ckeditor5/src/theme/ck-content.css?raw";
|
||||||
|
|
||||||
|
export async function standaloneZipExportProviderFactory(format: ExportFormat, data: ZipExportProviderData): Promise<ZipExportProvider> {
|
||||||
|
switch (format) {
|
||||||
|
case "html": {
|
||||||
|
const { default: HtmlExportProvider } = await import("@triliumnext/core/src/services/export/zip/html.js");
|
||||||
|
return new HtmlExportProvider(data, { contentCss });
|
||||||
|
}
|
||||||
|
case "markdown": {
|
||||||
|
const { default: MarkdownExportProvider } = await import("@triliumnext/core/src/services/export/zip/markdown.js");
|
||||||
|
return new MarkdownExportProvider(data);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported export format: '${format}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
apps/client-standalone/src/lightweight/zip_provider.ts
Normal file
101
apps/client-standalone/src/lightweight/zip_provider.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { FileStream, ZipArchive, ZipEntry, ZipProvider } from "@triliumnext/core/src/services/zip_provider.js";
|
||||||
|
import { strToU8, unzip, zipSync } from "fflate";
|
||||||
|
|
||||||
|
type ZipOutput = {
|
||||||
|
send?: (body: unknown) => unknown;
|
||||||
|
write?: (chunk: Uint8Array | string) => unknown;
|
||||||
|
end?: (chunk?: Uint8Array | string) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BrowserZipArchive implements ZipArchive {
|
||||||
|
readonly #entries: Record<string, Uint8Array> = {};
|
||||||
|
#destination: ZipOutput | null = null;
|
||||||
|
|
||||||
|
append(content: string | Uint8Array, options: { name: string }) {
|
||||||
|
this.#entries[options.name] = typeof content === "string" ? strToU8(content) : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe(destination: unknown) {
|
||||||
|
this.#destination = destination as ZipOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalize(): Promise<void> {
|
||||||
|
if (!this.#destination) {
|
||||||
|
throw new Error("ZIP output destination not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = zipSync(this.#entries, { level: 9 });
|
||||||
|
|
||||||
|
if (typeof this.#destination.send === "function") {
|
||||||
|
this.#destination.send(content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.#destination.end === "function") {
|
||||||
|
if (typeof this.#destination.write === "function") {
|
||||||
|
this.#destination.write(content);
|
||||||
|
this.#destination.end();
|
||||||
|
} else {
|
||||||
|
this.#destination.end(content);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unsupported ZIP output destination.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BrowserZipProvider implements ZipProvider {
|
||||||
|
createZipArchive(): ZipArchive {
|
||||||
|
return new BrowserZipArchive();
|
||||||
|
}
|
||||||
|
|
||||||
|
createFileStream(_filePath: string): FileStream {
|
||||||
|
throw new Error("File stream creation is not supported in the browser.");
|
||||||
|
}
|
||||||
|
|
||||||
|
readZipFile(
|
||||||
|
buffer: Uint8Array,
|
||||||
|
processEntry: (entry: ZipEntry, readContent: () => Promise<Uint8Array>) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise<void>((res, rej) => {
|
||||||
|
unzip(buffer, async (err, files) => {
|
||||||
|
if (err) { rej(err); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [fileName, data] of Object.entries(files)) {
|
||||||
|
await processEntry(
|
||||||
|
{ fileName: decodeZipFileName(fileName) },
|
||||||
|
() => Promise.resolve(data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res();
|
||||||
|
} catch (e) {
|
||||||
|
rej(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fflate decodes ZIP entry filenames as CP437/Latin-1 unless the language
|
||||||
|
* encoding flag (general purpose bit 11) is set, but many real-world archives
|
||||||
|
* (e.g. those produced by macOS / Linux unzip / Python's zipfile) write UTF-8
|
||||||
|
* filenames without setting that flag. Recover the original UTF-8 bytes from
|
||||||
|
* fflate's per-byte string and re-decode them; if the result isn't valid
|
||||||
|
* UTF-8 we fall back to the as-decoded name.
|
||||||
|
*/
|
||||||
|
function decodeZipFileName(name: string): string {
|
||||||
|
const bytes = new Uint8Array(name.length);
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
bytes[i] = name.charCodeAt(i) & 0xff;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return utf8Decoder.decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
apps/client-standalone/src/local-bridge.ts
Normal file
115
apps/client-standalone/src/local-bridge.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import LocalServerWorker from "./local-server-worker?worker";
|
||||||
|
let localWorker: Worker | null = null;
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
function showFatalErrorDialog(message: string) {
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLocalServerWorker() {
|
||||||
|
if (localWorker) return localWorker;
|
||||||
|
localWorker = new LocalServerWorker();
|
||||||
|
localWorker.postMessage({ type: "INIT", queryString: location.search });
|
||||||
|
|
||||||
|
// Handle worker errors during initialization
|
||||||
|
localWorker.onerror = (event) => {
|
||||||
|
console.error("[LocalBridge] Worker error:", event);
|
||||||
|
// Reject all pending requests
|
||||||
|
for (const [, resolver] of pending) {
|
||||||
|
resolver.reject(new Error(`Worker error: ${event.message}`));
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
localWorker.onmessage = (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
|
||||||
|
// Handle fatal platform crashes (shown as a dialog to the user)
|
||||||
|
if (msg?.type === "FATAL_ERROR") {
|
||||||
|
console.error("[LocalBridge] Fatal error:", msg.message);
|
||||||
|
showFatalErrorDialog(msg.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 [, resolver] of pending) {
|
||||||
|
resolver.reject(new Error(msg.error?.message || "Unknown worker error"));
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle WebSocket-like messages from the worker (for frontend updates)
|
||||||
|
if (msg?.type === "WS_MESSAGE" && msg.message) {
|
||||||
|
// Dispatch a custom event that ws.ts listens to in standalone mode
|
||||||
|
window.dispatchEvent(new CustomEvent("trilium:ws-message", {
|
||||||
|
detail: msg.message
|
||||||
|
}));
|
||||||
|
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() {
|
||||||
|
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) {
|
||||||
|
console.warn("[LocalBridge] Service workers not available — skipping bridge setup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<{ body?: ArrayBuffer }>((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: unknown) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(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(errorMessage).buffer
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
520
apps/client-standalone/src/local-server-worker.ts
Normal file
520
apps/client-standalone/src/local-server-worker.ts
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// ERROR HANDLERS FIRST - No static imports above this!
|
||||||
|
// ES modules hoist static imports, so they execute BEFORE any code runs.
|
||||||
|
// We use dynamic imports below to ensure error handlers are registered first.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
self.onerror = (message, source, lineno, colno, error) => {
|
||||||
|
const errorMsg = `[Worker] Uncaught error: ${message}\n at ${source}:${lineno}:${colno}`;
|
||||||
|
console.error(errorMsg, error);
|
||||||
|
try {
|
||||||
|
self.postMessage({
|
||||||
|
type: "WORKER_ERROR",
|
||||||
|
error: {
|
||||||
|
message: String(message),
|
||||||
|
source,
|
||||||
|
lineno,
|
||||||
|
colno,
|
||||||
|
stack: error?.stack || new Error().stack
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Worker] Failed to report error:", e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.onunhandledrejection = (event) => {
|
||||||
|
const reason = event.reason;
|
||||||
|
const errorMsg = `[Worker] Unhandled rejection: ${reason?.message || reason}`;
|
||||||
|
console.error(errorMsg, reason);
|
||||||
|
try {
|
||||||
|
self.postMessage({
|
||||||
|
type: "WORKER_ERROR",
|
||||||
|
error: {
|
||||||
|
message: String(reason?.message || reason),
|
||||||
|
stack: reason?.stack || new Error().stack
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Worker] Failed to report rejection:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[Worker] Error handlers installed, loading modules...");
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPE-ONLY IMPORTS (erased at runtime, safe as static imports)
|
||||||
|
// =============================================================================
|
||||||
|
import type { BrowserRouter } from './lightweight/browser_router';
|
||||||
|
|
||||||
|
// Build-time constant injected by Vite (see `define` in vite.config.mts).
|
||||||
|
declare const __TRILIUM_INTEGRATION_TEST__: string;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MODULE STATE (populated by dynamic imports)
|
||||||
|
// =============================================================================
|
||||||
|
let BrowserSqlProvider: typeof import('./lightweight/sql_provider').default;
|
||||||
|
let WorkerMessagingProvider: typeof import('./lightweight/messaging_provider').default;
|
||||||
|
let BrowserExecutionContext: typeof import('./lightweight/cls_provider').default;
|
||||||
|
let BrowserCryptoProvider: typeof import('./lightweight/crypto_provider').default;
|
||||||
|
let BrowserZipProvider: typeof import('./lightweight/zip_provider').default;
|
||||||
|
let FetchRequestProvider: typeof import('./lightweight/request_provider').default;
|
||||||
|
let StandalonePlatformProvider: typeof import('./lightweight/platform_provider').default;
|
||||||
|
let StandaloneLogService: typeof import('./lightweight/log_provider').default;
|
||||||
|
let StandaloneBackupService: typeof import('./lightweight/backup_provider').default;
|
||||||
|
let translationProvider: typeof import('./lightweight/translation_provider').default;
|
||||||
|
let createConfiguredRouter: typeof import('./lightweight/browser_routes').createConfiguredRouter;
|
||||||
|
|
||||||
|
// Instance state
|
||||||
|
let sqlProvider: InstanceType<typeof BrowserSqlProvider> | null = null;
|
||||||
|
let messagingProvider: InstanceType<typeof WorkerMessagingProvider> | null = null;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
let queryString = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a file exists at the OPFS root. Used to decide whether the
|
||||||
|
* test fixture needs to be seeded or whether we should reuse the existing
|
||||||
|
* DB (preserving changes made earlier in the same test — e.g. options set
|
||||||
|
* before a page reload).
|
||||||
|
*/
|
||||||
|
async function opfsFileExists(fileName: string): Promise<boolean> {
|
||||||
|
if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
try {
|
||||||
|
await root.getFileHandle(fileName);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a raw byte buffer to an OPFS file. Used to drop the test fixture DB
|
||||||
|
* into OPFS as a regular file so SQLite's OPFS VFS can then open it. Requires
|
||||||
|
* a Worker context (`createSyncAccessHandle` isn't available on the main thread
|
||||||
|
* in some browsers).
|
||||||
|
*/
|
||||||
|
async function writeOpfsFile(fileName: string, buffer: Uint8Array): Promise<void> {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const fileHandle = await root.getFileHandle(fileName, { create: true });
|
||||||
|
const accessHandle = await (fileHandle as unknown as {
|
||||||
|
createSyncAccessHandle(): Promise<{
|
||||||
|
truncate(size: number): void;
|
||||||
|
write(buffer: Uint8Array, opts: { at: number }): number;
|
||||||
|
flush(): void;
|
||||||
|
close(): void;
|
||||||
|
}>;
|
||||||
|
}).createSyncAccessHandle();
|
||||||
|
try {
|
||||||
|
accessHandle.truncate(0);
|
||||||
|
accessHandle.write(buffer, { at: 0 });
|
||||||
|
accessHandle.flush();
|
||||||
|
} finally {
|
||||||
|
accessHandle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a file from the OPFS root into a Uint8Array.
|
||||||
|
* Used during migration from legacy OPFS VFS to SAHPool.
|
||||||
|
*/
|
||||||
|
async function readOpfsFile(fileName: string): Promise<Uint8Array> {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const fileHandle = await root.getFileHandle(fileName);
|
||||||
|
const file = await fileHandle.getFile();
|
||||||
|
return new Uint8Array(await file.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from the OPFS root.
|
||||||
|
* Used to clean up the legacy OPFS database after migration to SAHPool.
|
||||||
|
*/
|
||||||
|
async function deleteOpfsFile(fileName: string): Promise<void> {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
await root.removeEntry(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that a buffer contains a valid SQLite database by checking the
|
||||||
|
* 16-byte magic string "SQLite format 3\0".
|
||||||
|
*/
|
||||||
|
function assertSqliteMagic(buffer: Uint8Array, source: string): void {
|
||||||
|
const magic = new TextDecoder().decode(buffer.subarray(0, 15));
|
||||||
|
if (magic !== "SQLite format 3") {
|
||||||
|
throw new Error(
|
||||||
|
`${source} is not a SQLite database ` +
|
||||||
|
`(got ${buffer.byteLength} bytes starting with "${magic}"). ` +
|
||||||
|
`The file is likely missing and the SPA fallback is returning index.html.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate database from legacy OPFS VFS to SAHPool VFS.
|
||||||
|
* Checks if a legacy `/trilium.db` file exists in the OPFS root, and if the
|
||||||
|
* SAHPool doesn't already have it. If migration is needed, the legacy file is
|
||||||
|
* read, imported into the pool, and then deleted.
|
||||||
|
*/
|
||||||
|
async function migrateFromLegacyOpfs(dbName: string): Promise<void> {
|
||||||
|
const legacyFileName = dbName.replace(/^\//, ""); // strip leading slash
|
||||||
|
const legacyExists = await opfsFileExists(legacyFileName);
|
||||||
|
|
||||||
|
if (!legacyExists) {
|
||||||
|
return; // Nothing to migrate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SAHPool already has this DB (e.g. migration already happened)
|
||||||
|
const poolFiles = sqlProvider!.sahPool!.getFileNames();
|
||||||
|
if (poolFiles.includes(dbName)) {
|
||||||
|
console.log("[Worker] SAHPool already contains the database, deleting legacy OPFS file...");
|
||||||
|
await deleteOpfsFile(legacyFileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Worker] Migrating database from legacy OPFS to SAHPool VFS...");
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const buffer = await readOpfsFile(legacyFileName);
|
||||||
|
assertSqliteMagic(buffer, "Legacy OPFS database");
|
||||||
|
|
||||||
|
await sqlProvider!.sahPool!.importDb(dbName, buffer);
|
||||||
|
await deleteOpfsFile(legacyFileName);
|
||||||
|
|
||||||
|
// Also clean up legacy journal/WAL files if they exist
|
||||||
|
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
||||||
|
try {
|
||||||
|
await deleteOpfsFile(legacyFileName + suffix);
|
||||||
|
} catch {
|
||||||
|
// Ignore — file may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - startTime;
|
||||||
|
console.log(`[Worker] Migration complete in ${elapsed.toFixed(2)}ms (${buffer.byteLength} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the test fixture database for integration tests.
|
||||||
|
* Seeds from the fixture if not already present, using SAHPool when available.
|
||||||
|
*/
|
||||||
|
async function loadTestDatabase(sahPoolAvailable: boolean, dbName: string): Promise<void> {
|
||||||
|
if (sahPoolAvailable) {
|
||||||
|
const poolFiles = sqlProvider!.sahPool!.getFileNames();
|
||||||
|
if (!poolFiles.includes(dbName)) {
|
||||||
|
console.log("[Worker] Integration test mode: seeding fixture database into SAHPool...");
|
||||||
|
const buffer = await fetchTestFixture();
|
||||||
|
await sqlProvider!.sahPool!.importDb(dbName, buffer);
|
||||||
|
} else {
|
||||||
|
console.log("[Worker] Integration test mode: reusing existing SAHPool DB from earlier in this test");
|
||||||
|
}
|
||||||
|
sqlProvider!.loadFromSahPool(dbName);
|
||||||
|
} else {
|
||||||
|
// Fallback to legacy OPFS for tests when SAHPool isn't available
|
||||||
|
const legacyFileName = dbName.replace(/^\//, "");
|
||||||
|
if (!(await opfsFileExists(legacyFileName))) {
|
||||||
|
console.log("[Worker] Integration test mode: seeding fixture database into OPFS...");
|
||||||
|
const buffer = await fetchTestFixture();
|
||||||
|
await writeOpfsFile(legacyFileName, buffer);
|
||||||
|
} else {
|
||||||
|
console.log("[Worker] Integration test mode: reusing existing OPFS DB from earlier in this test");
|
||||||
|
}
|
||||||
|
sqlProvider!.loadFromOpfs(dbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the test fixture database and validate it.
|
||||||
|
*/
|
||||||
|
async function fetchTestFixture(): Promise<Uint8Array> {
|
||||||
|
const response = await fetch("/test-fixtures/document.db");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch test fixture: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const buffer = new Uint8Array(await response.arrayBuffer());
|
||||||
|
assertSqliteMagic(buffer, "Test fixture at /test-fixtures/document.db");
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all required modules using dynamic imports.
|
||||||
|
* This allows errors to be caught by our error handlers.
|
||||||
|
*/
|
||||||
|
async function loadModules(): Promise<void> {
|
||||||
|
console.log("[Worker] Loading lightweight modules...");
|
||||||
|
const [
|
||||||
|
sqlModule,
|
||||||
|
messagingModule,
|
||||||
|
clsModule,
|
||||||
|
cryptoModule,
|
||||||
|
zipModule,
|
||||||
|
requestModule,
|
||||||
|
platformModule,
|
||||||
|
logModule,
|
||||||
|
backupModule,
|
||||||
|
translationModule,
|
||||||
|
routesModule
|
||||||
|
] = await Promise.all([
|
||||||
|
import('./lightweight/sql_provider.js'),
|
||||||
|
import('./lightweight/messaging_provider.js'),
|
||||||
|
import('./lightweight/cls_provider.js'),
|
||||||
|
import('./lightweight/crypto_provider.js'),
|
||||||
|
import('./lightweight/zip_provider.js'),
|
||||||
|
import('./lightweight/request_provider.js'),
|
||||||
|
import('./lightweight/platform_provider.js'),
|
||||||
|
import('./lightweight/log_provider.js'),
|
||||||
|
import('./lightweight/backup_provider.js'),
|
||||||
|
import('./lightweight/translation_provider.js'),
|
||||||
|
import('./lightweight/browser_routes.js')
|
||||||
|
]);
|
||||||
|
|
||||||
|
BrowserSqlProvider = sqlModule.default;
|
||||||
|
WorkerMessagingProvider = messagingModule.default;
|
||||||
|
BrowserExecutionContext = clsModule.default;
|
||||||
|
BrowserCryptoProvider = cryptoModule.default;
|
||||||
|
BrowserZipProvider = zipModule.default;
|
||||||
|
FetchRequestProvider = requestModule.default;
|
||||||
|
StandalonePlatformProvider = platformModule.default;
|
||||||
|
StandaloneLogService = logModule.default;
|
||||||
|
StandaloneBackupService = backupModule.default;
|
||||||
|
translationProvider = translationModule.default;
|
||||||
|
createConfiguredRouter = routesModule.createConfiguredRouter;
|
||||||
|
|
||||||
|
// Create instances
|
||||||
|
sqlProvider = new BrowserSqlProvider();
|
||||||
|
messagingProvider = new WorkerMessagingProvider();
|
||||||
|
|
||||||
|
console.log("[Worker] Lightweight modules loaded successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
// First, load all modules dynamically
|
||||||
|
await loadModules();
|
||||||
|
|
||||||
|
console.log("[Worker] Initializing SQLite WASM...");
|
||||||
|
await sqlProvider!.initWasm();
|
||||||
|
|
||||||
|
// Try to install the SAHPool VFS (preferred: supports WAL, much faster)
|
||||||
|
let sahPoolAvailable = false;
|
||||||
|
try {
|
||||||
|
await sqlProvider!.installSahPool();
|
||||||
|
sahPoolAvailable = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Worker] SAHPool VFS not available, will fall back to legacy OPFS or in-memory:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration test mode is baked in at build time via the
|
||||||
|
// __TRILIUM_INTEGRATION_TEST__ Vite define (derived from the
|
||||||
|
// TRILIUM_INTEGRATION_TEST env var when the bundle was built).
|
||||||
|
const integrationTestMode = __TRILIUM_INTEGRATION_TEST__;
|
||||||
|
const dbName = "/trilium.db";
|
||||||
|
|
||||||
|
if (integrationTestMode === "memory") {
|
||||||
|
// Use OPFS for the DB in integration test mode so option changes
|
||||||
|
// (and any other writes) survive page reloads within a single test.
|
||||||
|
// Playwright gives each test a fresh BrowserContext, which means a
|
||||||
|
// fresh OPFS — so on the first worker init of a test we seed from
|
||||||
|
// the fixture, and subsequent inits in the same test reuse it.
|
||||||
|
await loadTestDatabase(sahPoolAvailable, dbName);
|
||||||
|
} else if (sahPoolAvailable) {
|
||||||
|
// SAHPool available — migrate from legacy OPFS if needed, then open
|
||||||
|
await migrateFromLegacyOpfs(dbName);
|
||||||
|
console.log("[Worker] SAHPool available, loading persistent database (WAL mode)...");
|
||||||
|
sqlProvider!.loadFromSahPool(dbName);
|
||||||
|
} else if (sqlProvider!.isOpfsAvailable()) {
|
||||||
|
// Fall back to legacy OPFS VFS (no WAL, slower writes).
|
||||||
|
// This only kicks in if SAHPool installation failed for some
|
||||||
|
// reason but SharedArrayBuffer + legacy OPFS are both available.
|
||||||
|
console.warn("[Worker] SAHPool unavailable; using legacy OPFS VFS (no WAL mode).");
|
||||||
|
sqlProvider!.loadFromOpfs(dbName);
|
||||||
|
} else {
|
||||||
|
// Fall back to in-memory database (non-persistent).
|
||||||
|
// SAHPool only needs a Worker + OPFS API, so reaching this
|
||||||
|
// branch means the environment lacks OPFS entirely.
|
||||||
|
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
|
||||||
|
sqlProvider!.loadFromMemory();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Worker] Database loaded");
|
||||||
|
|
||||||
|
console.log("[Worker] Loading @triliumnext/core...");
|
||||||
|
const schemaModule = await import("@triliumnext/core/src/assets/schema.sql?raw");
|
||||||
|
coreModule = await import("@triliumnext/core");
|
||||||
|
|
||||||
|
// Initialize log service with OPFS persistence
|
||||||
|
const logService = new StandaloneLogService();
|
||||||
|
await logService.initialize();
|
||||||
|
console.log("[Worker] Log service initialized with OPFS");
|
||||||
|
|
||||||
|
await coreModule.initializeCore({
|
||||||
|
executionContext: new BrowserExecutionContext(),
|
||||||
|
crypto: new BrowserCryptoProvider(),
|
||||||
|
zip: new BrowserZipProvider(),
|
||||||
|
zipExportProviderFactory: (await import("./lightweight/zip_export_provider_factory.js")).standaloneZipExportProviderFactory,
|
||||||
|
messaging: messagingProvider!,
|
||||||
|
request: new FetchRequestProvider(),
|
||||||
|
platform: new StandalonePlatformProvider(queryString),
|
||||||
|
log: logService,
|
||||||
|
backup: new StandaloneBackupService(coreModule!.options),
|
||||||
|
translations: translationProvider,
|
||||||
|
schema: schemaModule.default,
|
||||||
|
getDemoArchive: async () => {
|
||||||
|
const response = await fetch("/server-assets/db/demo.zip");
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return new Uint8Array(await response.arrayBuffer());
|
||||||
|
},
|
||||||
|
image: (await import("./services/image_provider.js")).standaloneImageProvider,
|
||||||
|
dbConfig: {
|
||||||
|
provider: sqlProvider!,
|
||||||
|
isReadOnly: false,
|
||||||
|
onTransactionCommit: () => {
|
||||||
|
coreModule?.ws.sendTransactionEntityChangesToAllClients();
|
||||||
|
},
|
||||||
|
onTransactionRollback: () => {
|
||||||
|
// No-op for now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
coreModule.ws.init();
|
||||||
|
|
||||||
|
console.log("[Worker] Supported routes", Object.keys(coreModule.routes));
|
||||||
|
|
||||||
|
// Create and configure the router
|
||||||
|
router = createConfiguredRouter();
|
||||||
|
console.log("[Worker] Router configured");
|
||||||
|
|
||||||
|
// initializeDb runs initDbConnection inside an execution context,
|
||||||
|
// which resolves dbReady — required before beccaLoaded can settle.
|
||||||
|
coreModule.sql_init.initializeDb();
|
||||||
|
|
||||||
|
if (coreModule.sql_init.isDbInitialized()) {
|
||||||
|
console.log("[Worker] Database already initialized, loading becca...");
|
||||||
|
await coreModule.becca_loader.beccaLoaded;
|
||||||
|
|
||||||
|
// `initTranslations` runs before `initSql` inside `initializeCore`
|
||||||
|
// (options_init needs translations, creating a chicken-and-egg),
|
||||||
|
// so it always defaults to "en" on a fresh worker boot. Now that
|
||||||
|
// the DB is up we can read the real locale and, if it differs,
|
||||||
|
// switch i18next and rebuild the hidden subtree with the correct
|
||||||
|
// titles. This must happen BEFORE `startScheduler` registers its
|
||||||
|
// own `dbReady.then(checkHiddenSubtree)` so the scheduled rebuild
|
||||||
|
// sees the right language.
|
||||||
|
const dbLocale = coreModule.options.getOptionOrNull("locale");
|
||||||
|
if (dbLocale && dbLocale !== "en") {
|
||||||
|
console.log(`[Worker] Reconciling i18next locale to "${dbLocale}" from DB`);
|
||||||
|
await coreModule.i18n.changeLanguage(dbLocale);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("[Worker] Database not initialized, skipping becca load (will be loaded during DB initialization)");
|
||||||
|
}
|
||||||
|
|
||||||
|
coreModule.scheduler.startScheduler();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalRequest {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
body?: unknown;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main dispatch
|
||||||
|
async function dispatch(request: LocalRequest) {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the INIT message before initializing so that queryString
|
||||||
|
// (which may contain ?integrationTest=memory for e2e) is available.
|
||||||
|
let initReceived = false;
|
||||||
|
|
||||||
|
self.onmessage = async (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
if (msg.type === "INIT") {
|
||||||
|
queryString = msg.queryString || "";
|
||||||
|
if (!initReceived) {
|
||||||
|
initReceived = true;
|
||||||
|
console.log("[Worker] Starting initialization...");
|
||||||
|
initialize().catch(err => {
|
||||||
|
console.error("[Worker] Initialization failed:", err);
|
||||||
|
self.postMessage({
|
||||||
|
type: "WORKER_ERROR",
|
||||||
|
error: {
|
||||||
|
message: String(err?.message || err),
|
||||||
|
stack: err?.stack
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type !== "LOCAL_REQUEST") return;
|
||||||
|
|
||||||
|
const { id, request } = msg;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await dispatch(request);
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
97
apps/client-standalone/src/main.ts
Normal file
97
apps/client-standalone/src/main.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
|
||||||
|
|
||||||
|
async function waitForServiceWorkerControl(): Promise<void> {
|
||||||
|
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) {
|
||||||
|
const isSecure = location.protocol === "https:" || location.hostname === "localhost" || location.hostname === "127.0.0.1";
|
||||||
|
const hints: string[] = [];
|
||||||
|
if (!isSecure) {
|
||||||
|
hints.push(`The page is served over ${location.protocol}//${location.hostname} which is not a secure context. Service workers require HTTPS (or localhost).`);
|
||||||
|
}
|
||||||
|
if (window.isSecureContext === false) {
|
||||||
|
hints.push("The browser reports this is not a secure context.");
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Service workers are not available in this browser.\n\n" +
|
||||||
|
"Trilium standalone mode requires service workers to function.\n" +
|
||||||
|
(hints.length ? "\nPossible cause:\n- " + hints.join("\n- ") + "\n" : "") +
|
||||||
|
"\nTo fix this, access the application over HTTPS or via localhost."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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 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();
|
||||||
|
|
||||||
|
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; white-space: pre-wrap; word-wrap: break-word;">${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 loadScripts() {
|
||||||
|
await import("../../client/src/index.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
67
apps/client-standalone/src/services/data_encryption.spec.ts
Normal file
67
apps/client-standalone/src/services/data_encryption.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { data_encryption } from "@triliumnext/core";
|
||||||
|
|
||||||
|
// Note: BrowserCryptoProvider is already initialized via test_setup.ts
|
||||||
|
|
||||||
|
describe("data_encryption with BrowserCryptoProvider", () => {
|
||||||
|
it("should encrypt and decrypt ASCII text correctly", () => {
|
||||||
|
const key = new Uint8Array(16).fill(42);
|
||||||
|
const plainText = "Hello, World!";
|
||||||
|
|
||||||
|
const encrypted = data_encryption.encrypt(key, plainText);
|
||||||
|
expect(typeof encrypted).toBe("string");
|
||||||
|
expect(encrypted.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const decrypted = data_encryption.decryptString(key, encrypted);
|
||||||
|
expect(decrypted).toBe(plainText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt and decrypt UTF-8 text correctly", () => {
|
||||||
|
const key = new Uint8Array(16).fill(42);
|
||||||
|
const plainText = "Привет мир! 你好世界! 🎉";
|
||||||
|
|
||||||
|
const encrypted = data_encryption.encrypt(key, plainText);
|
||||||
|
const decrypted = data_encryption.decryptString(key, encrypted);
|
||||||
|
expect(decrypted).toBe(plainText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt and decrypt empty string", () => {
|
||||||
|
const key = new Uint8Array(16).fill(42);
|
||||||
|
const plainText = "";
|
||||||
|
|
||||||
|
const encrypted = data_encryption.encrypt(key, plainText);
|
||||||
|
const decrypted = data_encryption.decryptString(key, encrypted);
|
||||||
|
expect(decrypted).toBe(plainText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt and decrypt binary data", () => {
|
||||||
|
const key = new Uint8Array(16).fill(42);
|
||||||
|
const plainData = new Uint8Array([0, 1, 2, 255, 128, 64]);
|
||||||
|
|
||||||
|
const encrypted = data_encryption.encrypt(key, plainData);
|
||||||
|
const decrypted = data_encryption.decrypt(key, encrypted);
|
||||||
|
expect(decrypted).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(Array.from(decrypted as Uint8Array)).toEqual(Array.from(plainData));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail decryption with wrong key", () => {
|
||||||
|
const key1 = new Uint8Array(16).fill(42);
|
||||||
|
const key2 = new Uint8Array(16).fill(43);
|
||||||
|
const plainText = "Secret message";
|
||||||
|
|
||||||
|
const encrypted = data_encryption.encrypt(key1, plainText);
|
||||||
|
|
||||||
|
// decrypt returns false when digest doesn't match
|
||||||
|
const result = data_encryption.decrypt(key2, encrypted);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle large content", () => {
|
||||||
|
const key = new Uint8Array(16).fill(42);
|
||||||
|
const plainText = "x".repeat(100000);
|
||||||
|
|
||||||
|
const encrypted = data_encryption.encrypt(key, plainText);
|
||||||
|
const decrypted = data_encryption.decryptString(key, encrypted);
|
||||||
|
expect(decrypted).toBe(plainText);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
apps/client-standalone/src/services/image_provider.ts
Normal file
96
apps/client-standalone/src/services/image_provider.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Standalone image provider implementation.
|
||||||
|
* Uses pure JavaScript for format detection without compression.
|
||||||
|
* Images are saved as-is without resizing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ImageProvider, ImageFormat, ProcessedImage } from "@triliumnext/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect image type from buffer using magic bytes.
|
||||||
|
*/
|
||||||
|
function getImageTypeFromBuffer(buffer: Uint8Array): ImageFormat | null {
|
||||||
|
if (buffer.length < 12) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for SVG (text-based)
|
||||||
|
if (isSvg(buffer)) {
|
||||||
|
return { ext: "svg", mime: "image/svg+xml" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// JPEG: FF D8 FF
|
||||||
|
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
return { ext: "jpg", mime: "image/jpeg" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if (
|
||||||
|
buffer[0] === 0x89 &&
|
||||||
|
buffer[1] === 0x50 &&
|
||||||
|
buffer[2] === 0x4e &&
|
||||||
|
buffer[3] === 0x47 &&
|
||||||
|
buffer[4] === 0x0d &&
|
||||||
|
buffer[5] === 0x0a &&
|
||||||
|
buffer[6] === 0x1a &&
|
||||||
|
buffer[7] === 0x0a
|
||||||
|
) {
|
||||||
|
return { ext: "png", mime: "image/png" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIF: "GIF"
|
||||||
|
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
|
||||||
|
return { ext: "gif", mime: "image/gif" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP: RIFF....WEBP
|
||||||
|
if (
|
||||||
|
buffer[0] === 0x52 &&
|
||||||
|
buffer[1] === 0x49 &&
|
||||||
|
buffer[2] === 0x46 &&
|
||||||
|
buffer[3] === 0x46 &&
|
||||||
|
buffer[8] === 0x57 &&
|
||||||
|
buffer[9] === 0x45 &&
|
||||||
|
buffer[10] === 0x42 &&
|
||||||
|
buffer[11] === 0x50
|
||||||
|
) {
|
||||||
|
return { ext: "webp", mime: "image/webp" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// BMP: "BM"
|
||||||
|
if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
|
||||||
|
return { ext: "bmp", mime: "image/bmp" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if buffer contains SVG content.
|
||||||
|
*/
|
||||||
|
function isSvg(buffer: Uint8Array): boolean {
|
||||||
|
const maxBytes = Math.min(buffer.length, 1000);
|
||||||
|
let str = "";
|
||||||
|
for (let i = 0; i < maxBytes; i++) {
|
||||||
|
str += String.fromCharCode(buffer[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = str.trim().toLowerCase();
|
||||||
|
return trimmed.startsWith("<svg") || (trimmed.startsWith("<?xml") && trimmed.includes("<svg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const standaloneImageProvider: ImageProvider = {
|
||||||
|
getImageType(buffer: Uint8Array): ImageFormat | null {
|
||||||
|
return getImageTypeFromBuffer(buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
async processImage(buffer: Uint8Array, _originalName: string, _shrink: boolean): Promise<ProcessedImage> {
|
||||||
|
// Standalone doesn't do compression - just detect format and return original
|
||||||
|
const format = getImageTypeFromBuffer(buffer) || { ext: "dat", mime: "application/octet-stream" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
format
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
196
apps/client-standalone/src/sw.ts
Normal file
196
apps/client-standalone/src/sw.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// 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 the main app window to handle the request
|
||||||
|
// We must route to the main app (which has the local bridge), not iframes like PDF.js viewer
|
||||||
|
// @ts-expect-error - self.clients is valid in service worker context
|
||||||
|
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
|
||||||
|
// Find the main app window - it's the one NOT serving pdfjs or other embedded content
|
||||||
|
// The main app has the local bridge handler for LOCAL_FETCH messages
|
||||||
|
let client = all.find((c: { url: string }) => {
|
||||||
|
const url = new URL(c.url);
|
||||||
|
// Main app is at root or index.html, not in /pdfjs/ or other iframe paths
|
||||||
|
return !url.pathname.startsWith("/pdfjs/");
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
// If no main app window found, fall back to any available client
|
||||||
|
if (!client) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// API-ish: local-first via bridge (must be checked before navigate handling,
|
||||||
|
// because export triggers a navigation to an /api/ URL)
|
||||||
|
if (isLocalFirst(url)) {
|
||||||
|
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
|
||||||
|
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") {
|
||||||
|
event.respondWith(cacheFirst(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
});
|
||||||
137
apps/client-standalone/src/test_setup.ts
Normal file
137
apps/client-standalone/src/test_setup.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { initializeCore, options } from "@triliumnext/core";
|
||||||
|
import schemaSql from "@triliumnext/core/src/assets/schema.sql?raw";
|
||||||
|
import serverEnTranslations from "../../server/src/assets/translations/en/server.json";
|
||||||
|
import { beforeAll } from "vitest";
|
||||||
|
|
||||||
|
import StandaloneBackupService from "./lightweight/backup_provider.js";
|
||||||
|
import BrowserExecutionContext from "./lightweight/cls_provider.js";
|
||||||
|
import BrowserCryptoProvider from "./lightweight/crypto_provider.js";
|
||||||
|
import StandalonePlatformProvider from "./lightweight/platform_provider.js";
|
||||||
|
import BrowserSqlProvider from "./lightweight/sql_provider.js";
|
||||||
|
import BrowserZipProvider from "./lightweight/zip_provider.js";
|
||||||
|
import { standaloneImageProvider } from "./services/image_provider.js";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SQLite WASM compatibility shims
|
||||||
|
// =============================================================================
|
||||||
|
// The @sqlite.org/sqlite-wasm package loads its .wasm via fetch, and its
|
||||||
|
// bundled `instantiateWasm` hook overrides any user-supplied alternative.
|
||||||
|
// Two things go wrong under vitest + happy-dom:
|
||||||
|
// 1. happy-dom's `fetch()` refuses `file://` URLs.
|
||||||
|
// 2. happy-dom installs its own Response global, which Node's
|
||||||
|
// `WebAssembly.instantiateStreaming` rejects ("Received an instance of
|
||||||
|
// Response" — it wants undici's Response).
|
||||||
|
// We intercept fetch for file:// URLs ourselves and force instantiateStreaming
|
||||||
|
// to fall back to the ArrayBuffer path.
|
||||||
|
const fileFetchCache = new Map<string, ArrayBuffer>();
|
||||||
|
|
||||||
|
function readFileAsArrayBuffer(url: string): ArrayBuffer {
|
||||||
|
let cached = fileFetchCache.get(url);
|
||||||
|
if (!cached) {
|
||||||
|
const bytes = readFileSync(fileURLToPath(url));
|
||||||
|
cached = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
fileFetchCache.set(url, cached);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
|
||||||
|
if (url.startsWith("file://")) {
|
||||||
|
const body = readFileAsArrayBuffer(url);
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/wasm" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalFetch(input as RequestInfo, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
WebAssembly.instantiateStreaming = (async (source, importObject) => {
|
||||||
|
const response = await source;
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
return WebAssembly.instantiate(bytes, importObject);
|
||||||
|
}) as typeof WebAssembly.instantiateStreaming;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// happy-dom HTMLParser spec compliance patch
|
||||||
|
// =============================================================================
|
||||||
|
// Per HTML5 parsing spec, a single U+000A LINE FEED immediately after a <pre>,
|
||||||
|
// <listing>, or <textarea> start tag must be ignored ("newlines at the start
|
||||||
|
// of pre blocks are ignored as an authoring convenience"). Real browsers and
|
||||||
|
// domino (which turnish uses in Node) both implement this; happy-dom does not.
|
||||||
|
// Patch at the DOMParser boundary since turnish prefers DOMParser when it's
|
||||||
|
// available — patching via module-level HTMLParser import hits a different
|
||||||
|
// happy-dom copy than the vitest env loaded.
|
||||||
|
const LEADING_LF_IN_PRE_RE = /(<(?:pre|listing|textarea)\b[^>]*>)(\r\n|\r|\n)/gi;
|
||||||
|
const originalParseFromString = DOMParser.prototype.parseFromString;
|
||||||
|
DOMParser.prototype.parseFromString = function (source: string, type: DOMParserSupportedType) {
|
||||||
|
const patched = typeof source === "string"
|
||||||
|
? source.replace(LEADING_LF_IN_PRE_RE, "$1")
|
||||||
|
: source;
|
||||||
|
return originalParseFromString.call(this, patched, type);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Core initialization for standalone-flavored tests
|
||||||
|
// =============================================================================
|
||||||
|
// Mirror what apps/server/spec/setup.ts does: load the pre-seeded integration
|
||||||
|
// fixture DB into an in-memory sqlite-wasm instance, then initialize core
|
||||||
|
// against it with the standalone (browser) providers. Each vitest worker gets
|
||||||
|
// a fresh copy because tests run in forks (per the default pool).
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const fixtureDb = readFileSync(
|
||||||
|
require.resolve("@triliumnext/core/src/test/fixtures/document.db")
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const sqlProvider = new BrowserSqlProvider();
|
||||||
|
await sqlProvider.initWasm();
|
||||||
|
sqlProvider.loadFromBuffer(fixtureDb);
|
||||||
|
|
||||||
|
await initializeCore({
|
||||||
|
executionContext: new BrowserExecutionContext(),
|
||||||
|
crypto: new BrowserCryptoProvider(),
|
||||||
|
zip: new BrowserZipProvider(),
|
||||||
|
zipExportProviderFactory: (
|
||||||
|
await import("./lightweight/zip_export_provider_factory.js")
|
||||||
|
).standaloneZipExportProviderFactory,
|
||||||
|
// i18next must be wired up — keyboard_actions.ts and other modules
|
||||||
|
// call `t()` and throw if translations are missing. Inline the
|
||||||
|
// en/server.json resources via vite's JSON import so we don't need a
|
||||||
|
// backend in tests.
|
||||||
|
translations: async (i18nextInstance, locale) => {
|
||||||
|
await i18nextInstance.init({
|
||||||
|
lng: locale,
|
||||||
|
fallbackLng: "en",
|
||||||
|
ns: "server",
|
||||||
|
defaultNS: "server",
|
||||||
|
resources: {
|
||||||
|
en: { server: serverEnTranslations }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
platform: new StandalonePlatformProvider(""),
|
||||||
|
backup: new StandaloneBackupService(options),
|
||||||
|
image: standaloneImageProvider,
|
||||||
|
schema: schemaSql,
|
||||||
|
dbConfig: {
|
||||||
|
provider: sqlProvider,
|
||||||
|
isReadOnly: false,
|
||||||
|
onTransactionCommit: () => {},
|
||||||
|
onTransactionRollback: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
308
apps/client-standalone/vite.config.mts
Normal file
308
apps/client-standalone/vite.config.mts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import { join, resolve, sep } from "path";
|
||||||
|
|
||||||
|
import prefresh from "@prefresh/vite";
|
||||||
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||||
|
|
||||||
|
const clientAssets = ["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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve PDF.js files directly in dev mode to bypass SPA fallback
|
||||||
|
const pdfjsServePlugin = (): Plugin => ({
|
||||||
|
name: "pdfjs-serve",
|
||||||
|
configureServer(server) {
|
||||||
|
const pdfjsRoot = join(__dirname, "../../packages/pdfjs-viewer/dist");
|
||||||
|
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
if (!req.url?.startsWith("/pdfjs/")) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map /pdfjs/web/... to dist/web/...
|
||||||
|
// Map /pdfjs/build/... to dist/build/...
|
||||||
|
// Strip query string (e.g., ?v=0.102.2) before resolving path
|
||||||
|
const urlWithoutQuery = req.url.split("?")[0];
|
||||||
|
const relativePath = urlWithoutQuery.replace(/^\/pdfjs\//, "");
|
||||||
|
const filePath = join(pdfjsRoot, relativePath);
|
||||||
|
|
||||||
|
// Security: resolve both paths to prevent prefix-collision attacks
|
||||||
|
// (e.g. pdfjsRoot="/foo/bar" matching "/foo/bar2/evil.js")
|
||||||
|
const resolvedRoot = resolve(pdfjsRoot);
|
||||||
|
const resolvedFilePath = resolve(filePath);
|
||||||
|
if (!resolvedFilePath.startsWith(resolvedRoot + sep)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||||
|
const ext = filePath.split(".").pop() || "";
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
html: "text/html",
|
||||||
|
css: "text/css",
|
||||||
|
js: "application/javascript",
|
||||||
|
mjs: "application/javascript",
|
||||||
|
wasm: "application/wasm",
|
||||||
|
png: "image/png",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
json: "application/json"
|
||||||
|
};
|
||||||
|
res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
|
||||||
|
// Match isolation headers from main page for iframe compatibility
|
||||||
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||||
|
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
rename: { stripBase: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
|
||||||
|
dest: "assets",
|
||||||
|
rename: { stripBase: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let plugins: any = [
|
||||||
|
sqliteWasmPlugin, // Always include SQLite WASM files
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: clientAssets.map((asset) => ({
|
||||||
|
src: `../../client/src/${asset}/**/*`,
|
||||||
|
dest: asset,
|
||||||
|
rename: { stripBase: 3 }
|
||||||
|
})),
|
||||||
|
// Enable watching in development
|
||||||
|
...(isDev && {
|
||||||
|
watch: {
|
||||||
|
reloadPageOnChange: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
src: "../../server/src/assets/**/*",
|
||||||
|
dest: "server-assets",
|
||||||
|
rename: { stripBase: 3 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
// PDF.js viewer for PDF preview support
|
||||||
|
// stripBase: 4 removes packages/pdfjs-viewer/dist/web (or /build)
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
src: "../../../packages/pdfjs-viewer/dist/web/**/*",
|
||||||
|
dest: "pdfjs/web",
|
||||||
|
rename: { stripBase: 4 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "../../../packages/pdfjs-viewer/dist/build/**/*",
|
||||||
|
dest: "pdfjs/build",
|
||||||
|
rename: { stripBase: 4 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
// Watch client files for changes in development
|
||||||
|
...(isDev ? [
|
||||||
|
prefresh(),
|
||||||
|
clientWatchPlugin(),
|
||||||
|
pdfjsServePlugin()
|
||||||
|
] : [])
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isDev) {
|
||||||
|
plugins = [
|
||||||
|
...plugins,
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/**/*",
|
||||||
|
dest: "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the integration test fixture database for e2e tests
|
||||||
|
if (process.env.TRILIUM_INTEGRATION_TEST) {
|
||||||
|
plugins = [
|
||||||
|
...plugins,
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
// Forward slashes are required because fast-glob (used
|
||||||
|
// internally) treats backslashes as escape characters on
|
||||||
|
// Windows. `stripBase` drops the source's directory
|
||||||
|
// structure so the file lands flat at `test-fixtures/document.db`
|
||||||
|
// rather than mirroring the `packages/trilium-core/...` path.
|
||||||
|
src: join(__dirname, "../../packages/trilium-core/src/test/fixtures/document.db").replace(/\\/g, "/"),
|
||||||
|
dest: "test-fixtures",
|
||||||
|
rename: { stripBase: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
|
||||||
|
envDir: __dirname, // Load .env files from client-standalone directory, not src/
|
||||||
|
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
|
||||||
|
base: "",
|
||||||
|
plugins,
|
||||||
|
esbuild: {
|
||||||
|
jsx: 'automatic',
|
||||||
|
jsxImportSource: 'preact',
|
||||||
|
jsxDev: isDev
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
transformer: 'lightningcss',
|
||||||
|
devSourcemap: isDev
|
||||||
|
},
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
headers: {
|
||||||
|
"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'),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: (chunkInfo) => {
|
||||||
|
// Service worker and other workers should be at root level
|
||||||
|
if (chunkInfo.name === 'sw') {
|
||||||
|
return '[name].js';
|
||||||
|
}
|
||||||
|
return 'src/[name].js';
|
||||||
|
},
|
||||||
|
chunkFileNames: "src/[name].js",
|
||||||
|
assetFileNames: "src/[name].[ext]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "happy-dom",
|
||||||
|
setupFiles: [join(__dirname, "src/test_setup.ts")],
|
||||||
|
dir: join(__dirname),
|
||||||
|
include: [
|
||||||
|
"src/**/*.{test,spec}.{ts,tsx}",
|
||||||
|
"../../packages/trilium-core/src/**/*.{test,spec}.{ts,tsx}"
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
deps: {
|
||||||
|
inline: ["@sqlite.org/sqlite-wasm"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
alias: {
|
||||||
|
// The package's `node.mjs` entry references a non-existent
|
||||||
|
// `sqlite3-node.mjs`. Force the browser-style entry which works
|
||||||
|
// under Node + happy-dom too.
|
||||||
|
"@sqlite.org/sqlite-wasm": join(
|
||||||
|
__dirname,
|
||||||
|
"../../node_modules/@sqlite.org/sqlite-wasm/index.mjs"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||||
|
__TRILIUM_INTEGRATION_TEST__: JSON.stringify(process.env.TRILIUM_INTEGRATION_TEST ?? ""),
|
||||||
|
}
|
||||||
|
}));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@triliumnext/client",
|
"name": "@triliumnext/client",
|
||||||
"version": "0.102.1",
|
"version": "0.102.2",
|
||||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
@@ -27,50 +27,49 @@
|
|||||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||||
"@mermaid-js/layout-elk": "0.2.1",
|
"@mermaid-js/layout-elk": "0.2.1",
|
||||||
"@mind-elixir/node-menu": "5.0.1",
|
"@mind-elixir/node-menu": "5.0.1",
|
||||||
"@popperjs/core": "2.11.8",
|
"@preact/signals": "2.9.0",
|
||||||
"@preact/signals": "2.8.2",
|
|
||||||
"@triliumnext/ckeditor5": "workspace:*",
|
"@triliumnext/ckeditor5": "workspace:*",
|
||||||
"@triliumnext/codemirror": "workspace:*",
|
"@triliumnext/codemirror": "workspace:*",
|
||||||
"@triliumnext/commons": "workspace:*",
|
"@triliumnext/commons": "workspace:*",
|
||||||
"@triliumnext/highlightjs": "workspace:*",
|
"@triliumnext/highlightjs": "workspace:*",
|
||||||
"@triliumnext/share-theme": "workspace:*",
|
"@triliumnext/share-theme": "workspace:*",
|
||||||
"@triliumnext/split.js": "workspace:*",
|
"@triliumnext/split.js": "workspace:*",
|
||||||
"@univerjs/preset-sheets-conditional-formatting": "0.18.0",
|
"@univerjs/preset-sheets-conditional-formatting": "0.20.1",
|
||||||
"@univerjs/preset-sheets-core": "0.18.0",
|
"@univerjs/preset-sheets-core": "0.20.1",
|
||||||
"@univerjs/preset-sheets-data-validation": "0.18.0",
|
"@univerjs/preset-sheets-data-validation": "0.20.1",
|
||||||
"@univerjs/preset-sheets-filter": "0.18.0",
|
"@univerjs/preset-sheets-filter": "0.20.1",
|
||||||
"@univerjs/preset-sheets-find-replace": "0.18.0",
|
"@univerjs/preset-sheets-find-replace": "0.20.1",
|
||||||
"@univerjs/preset-sheets-note": "0.18.0",
|
"@univerjs/preset-sheets-note": "0.20.1",
|
||||||
"@univerjs/preset-sheets-sort": "0.18.0",
|
"@univerjs/preset-sheets-sort": "0.20.1",
|
||||||
"@univerjs/presets": "0.18.0",
|
"@univerjs/presets": "0.20.1",
|
||||||
"@zumer/snapdom": "2.6.0",
|
"@zumer/snapdom": "2.8.0",
|
||||||
"autocomplete.js": "0.38.1",
|
"autocomplete.js": "0.38.1",
|
||||||
"bootstrap": "5.3.8",
|
"bootstrap": "5.3.8",
|
||||||
"boxicons": "2.1.4",
|
"boxicons": "2.1.4",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"color": "5.0.3",
|
"color": "5.0.3",
|
||||||
"debounce": "3.0.0",
|
"debounce": "3.0.0",
|
||||||
|
"dompurify": "3.4.0",
|
||||||
"draggabilly": "3.0.0",
|
"draggabilly": "3.0.0",
|
||||||
"force-graph": "1.51.2",
|
"force-graph": "1.51.2",
|
||||||
"globals": "17.4.0",
|
"htmldiff-js": "1.0.5",
|
||||||
"i18next": "25.10.10",
|
"i18next": "26.0.4",
|
||||||
"i18next-http-backend": "3.0.2",
|
"i18next-http-backend": "3.0.4",
|
||||||
"jquery": "4.0.0",
|
"jquery": "4.0.0",
|
||||||
"jquery.fancytree": "2.38.5",
|
"jquery.fancytree": "2.38.5",
|
||||||
"jsplumb": "2.15.6",
|
"jsplumb": "2.15.6",
|
||||||
"katex": "0.16.43",
|
"katex": "0.16.45",
|
||||||
"leaflet": "1.9.4",
|
"leaflet": "1.9.4",
|
||||||
"leaflet-gpx": "2.2.0",
|
"leaflet-gpx": "2.2.0",
|
||||||
"mark.js": "8.11.1",
|
"mark.js": "8.11.1",
|
||||||
"marked": "17.0.5",
|
"marked": "18.0.0",
|
||||||
"mermaid": "11.13.0",
|
"mermaid": "11.14.0",
|
||||||
"mind-elixir": "5.9.3",
|
"mind-elixir": "5.10.0",
|
||||||
"normalize.css": "8.0.1",
|
|
||||||
"panzoom": "9.4.4",
|
"panzoom": "9.4.4",
|
||||||
"preact": "10.29.0",
|
"preact": "10.29.1",
|
||||||
"react-i18next": "16.6.6",
|
"react-i18next": "17.0.3",
|
||||||
"react-window": "2.2.7",
|
"react-window": "2.2.7",
|
||||||
"reveal.js": "6.0.0",
|
"reveal.js": "6.0.1",
|
||||||
"rrule": "2.8.1",
|
"rrule": "2.8.1",
|
||||||
"svg-pan-zoom": "3.6.2",
|
"svg-pan-zoom": "3.6.2",
|
||||||
"tabulator-tables": "6.4.0",
|
"tabulator-tables": "6.4.0",
|
||||||
@@ -78,7 +77,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||||
"@prefresh/vite": "2.4.12",
|
"@prefresh/vite": "3.0.0",
|
||||||
"@types/bootstrap": "5.2.10",
|
"@types/bootstrap": "5.2.10",
|
||||||
"@types/jquery": "4.0.0",
|
"@types/jquery": "4.0.0",
|
||||||
"@types/leaflet": "1.9.21",
|
"@types/leaflet": "1.9.21",
|
||||||
@@ -86,9 +85,9 @@
|
|||||||
"@types/mark.js": "8.11.12",
|
"@types/mark.js": "8.11.12",
|
||||||
"@types/tabulator-tables": "6.3.1",
|
"@types/tabulator-tables": "6.3.1",
|
||||||
"copy-webpack-plugin": "14.0.0",
|
"copy-webpack-plugin": "14.0.0",
|
||||||
"happy-dom": "20.8.9",
|
"happy-dom": "20.9.0",
|
||||||
"lightningcss": "1.32.0",
|
"lightningcss": "1.32.0",
|
||||||
"script-loader": "0.7.2",
|
"script-loader": "0.7.2",
|
||||||
"vite-plugin-static-copy": "3.4.0"
|
"vite-plugin-static-copy": "4.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
apps/client/src/assets/icon-classic.svg
Normal file
1
apps/client/src/assets/icon-classic.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve"><path d="M63.966,45.043c0.008-0.009,0.021-0.021,0.027-0.029c0.938-1.156-0.823-13.453-5.063-20.125 c-1.389-2.186-2.239-3.423-3.219-4.719c-3.907-5.166-6-6.125-6-6.125S35.732,24.78,36.149,44.315 c-1.754,0.065-11.218,7.528-14.826,14.388c-1.206,2.291-1.856,3.645-2.493,5.141c-2.539,5.957-2.33,8.25-2.33,8.25 s16.271,6.79,33.014-3.294c0.007,0.021,0.013,0.046,0.02,0.063c0.537,1.389,12.08,5.979,19.976,5.621 c2.587-0.116,4.084-0.238,5.696-0.444c6.424-0.818,8.298-2.157,8.298-2.157S81.144,54.396,63.966,45.043z M50.787,65.343 c1.059-1.183,4.648-5.853,0.995-11.315c-0.253-0.377-0.496-0.236-0.496-0.236s0.063,10.822-5.162,12.359 c-5.225,1.537-13.886,4.4-20.427,0.455C25,66.186,26.924,53.606,38.544,47.229c0.546,1.599,2.836,6.854,9.292,6.409 c0.453-0.031,0.453-0.313,0.453-0.313s-9.422-5.328-8.156-10.625s3.089-14.236,9.766-17.948c0.714-0.397,10.746,7.593,10.417,20.94 c-1.606-0.319-7.377-1.004-10.226,4.864c-0.198,0.409,0.046,0.549,0.046,0.549s9.31-5.521,13.275-1.789 c3.965,3.733,10.813,9.763,10.71,17.4C74.111,67.533,62.197,72.258,50.787,65.343z M35.613,35.145c0,0-0.991,3.241-0.603,7.524 l-13.393-7.524C21.618,35.145,27.838,30.931,35.613,35.145z M21.193,36.03l13.344,7.612c-3.872,1.872-6.142,4.388-6.142,4.388 C20.78,43.531,21.193,36.03,21.193,36.03z M72.287,49.064c0,0-2.321-2.471-6.23-4.263l13.187-7.881 C79.243,36.92,79.808,44.413,72.287,49.064z M78.687,36.113l-13.237,7.794c0.3-4.291-0.754-7.511-0.754-7.511 C72.383,32.025,78.687,36.113,78.687,36.113z M42.076,73.778c0,0,3.309-0.737,6.845-3.185l0.056,15.361 C48.977,85.955,42.244,82.621,42.076,73.778z M49.956,85.888L50,70.526c3.539,2.445,6.846,3.181,6.846,3.181 C56.686,82.551,49.956,85.888,49.956,85.888z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
28
apps/client/src/assets/icon-color.svg
Normal file
28
apps/client/src/assets/icon-color.svg
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg enable-background="new 0 0 256 256" version="1.1" viewBox="0 0 256 256" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Trilium Notes</title>
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#95C980;}
|
||||||
|
.st1{fill:#72B755;}
|
||||||
|
.st2{fill:#4FA52B;}
|
||||||
|
.st3{fill:#EE8C89;}
|
||||||
|
.st4{fill:#E96562;}
|
||||||
|
.st5{fill:#E33F3B;}
|
||||||
|
.st6{fill:#EFB075;}
|
||||||
|
.st7{fill:#E99547;}
|
||||||
|
.st8{fill:#E47B19;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
|
||||||
|
<path class="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
|
||||||
|
<path class="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
|
||||||
|
|
||||||
|
<path class="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
|
||||||
|
<path class="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
|
||||||
|
<path class="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
|
||||||
|
|
||||||
|
<path class="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
|
||||||
|
<path class="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
|
||||||
|
<path class="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
17
apps/client/src/assets/icon-nightly.svg
Normal file
17
apps/client/src/assets/icon-nightly.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg enable-background="new 0 0 256 256" version="1.1" viewBox="0 0 256 256" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Trilium Notes</title>
|
||||||
|
<g>
|
||||||
|
<path d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z" fill="#ab60e3"/>
|
||||||
|
<path d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z" fill="#8038b8"/>
|
||||||
|
<path d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z" fill="#560a8f"/>
|
||||||
|
|
||||||
|
<path d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z" fill="#bb9dd2"/>
|
||||||
|
<path d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z" fill="#9a6cbc"/>
|
||||||
|
<path d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z" fill="#783ba5"/>
|
||||||
|
|
||||||
|
<path d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z" fill="#ab60e3"/>
|
||||||
|
<path d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z" fill="#8038b8"/>
|
||||||
|
<path d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z" fill="#6f2796"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
28
apps/client/src/assets/icon.svg
Normal file
28
apps/client/src/assets/icon.svg
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg enable-background="new 0 0 256 256" version="1.1" viewBox="0 0 256 256" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Trilium Notes</title>
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#95C980;}
|
||||||
|
.st1{fill:#72B755;}
|
||||||
|
.st2{fill:#4FA52B;}
|
||||||
|
.st3{fill:#EE8C89;}
|
||||||
|
.st4{fill:#E96562;}
|
||||||
|
.st5{fill:#E33F3B;}
|
||||||
|
.st6{fill:#EFB075;}
|
||||||
|
.st7{fill:#E99547;}
|
||||||
|
.st8{fill:#E47B19;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
|
||||||
|
<path class="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
|
||||||
|
<path class="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
|
||||||
|
|
||||||
|
<path class="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
|
||||||
|
<path class="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
|
||||||
|
<path class="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
|
||||||
|
|
||||||
|
<path class="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
|
||||||
|
<path class="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
|
||||||
|
<path class="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,10 +1,11 @@
|
|||||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||||
import type CodeMirror from "@triliumnext/codemirror";
|
import type CodeMirror from "@triliumnext/codemirror";
|
||||||
import { SqlExecuteResponse } from "@triliumnext/commons";
|
import { type LOCALE_IDS, SqlExecuteResponse } from "@triliumnext/commons";
|
||||||
import type { NativeImage, TouchBar } from "electron";
|
import type { NativeImage, TouchBar } from "electron";
|
||||||
import { ColumnComponent } from "tabulator-tables";
|
import { ColumnComponent } from "tabulator-tables";
|
||||||
|
|
||||||
import type { Attribute } from "../services/attribute_parser.js";
|
import type { Attribute } from "../services/attribute_parser.js";
|
||||||
|
import bundleService from "../services/bundle.js";
|
||||||
import froca from "../services/froca.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 keyboardActionsService from "../services/keyboard_actions.js";
|
||||||
@@ -23,6 +24,7 @@ import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
|
|||||||
import type { InfoProps } from "../widgets/dialogs/info.jsx";
|
import type { InfoProps } from "../widgets/dialogs/info.jsx";
|
||||||
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
|
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
|
||||||
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
|
||||||
|
import type { PrintPreviewData } from "../widgets/dialogs/print_preview.jsx";
|
||||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||||
import type NoteTreeWidget from "../widgets/note_tree.js";
|
import type NoteTreeWidget from "../widgets/note_tree.js";
|
||||||
import Component from "./component.js";
|
import Component from "./component.js";
|
||||||
@@ -279,6 +281,7 @@ export type CommandMappings = {
|
|||||||
backInNoteHistory: CommandData;
|
backInNoteHistory: CommandData;
|
||||||
forwardInNoteHistory: CommandData;
|
forwardInNoteHistory: CommandData;
|
||||||
forceSaveRevision: CommandData;
|
forceSaveRevision: CommandData;
|
||||||
|
saveNamedRevision: CommandData;
|
||||||
scrollToActiveNote: CommandData;
|
scrollToActiveNote: CommandData;
|
||||||
quickSearch: CommandData;
|
quickSearch: CommandData;
|
||||||
collapseTree: CommandData;
|
collapseTree: CommandData;
|
||||||
@@ -302,6 +305,7 @@ export type CommandMappings = {
|
|||||||
ninthTab: CommandData;
|
ninthTab: CommandData;
|
||||||
lastTab: CommandData;
|
lastTab: CommandData;
|
||||||
showNoteSource: CommandData;
|
showNoteSource: CommandData;
|
||||||
|
showNoteOCRText: CommandData;
|
||||||
showSQLConsole: CommandData;
|
showSQLConsole: CommandData;
|
||||||
showBackendLog: CommandData;
|
showBackendLog: CommandData;
|
||||||
showCheatsheet: CommandData;
|
showCheatsheet: CommandData;
|
||||||
@@ -328,6 +332,7 @@ export type CommandMappings = {
|
|||||||
toggleRightPane: CommandData;
|
toggleRightPane: CommandData;
|
||||||
printActiveNote: CommandData;
|
printActiveNote: CommandData;
|
||||||
exportAsPdf: CommandData;
|
exportAsPdf: CommandData;
|
||||||
|
showPrintPreview: PrintPreviewData;
|
||||||
openNoteExternally: CommandData;
|
openNoteExternally: CommandData;
|
||||||
openNoteCustom: CommandData;
|
openNoteCustom: CommandData;
|
||||||
openNoteOnServer: CommandData;
|
openNoteOnServer: CommandData;
|
||||||
@@ -508,7 +513,7 @@ type EventMappings = {
|
|||||||
contentSafeMarginChanged: {
|
contentSafeMarginChanged: {
|
||||||
top: number;
|
top: number;
|
||||||
noteContext: NoteContext;
|
noteContext: NoteContext;
|
||||||
}
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EventListener<T extends EventNames> = {
|
export type EventListener<T extends EventNames> = {
|
||||||
@@ -562,7 +567,7 @@ export class AppContext extends Component {
|
|||||||
*/
|
*/
|
||||||
async earlyInit() {
|
async earlyInit() {
|
||||||
await options.initializedPromise;
|
await options.initializedPromise;
|
||||||
await initLocale();
|
await initLocale((options.get("locale") || "en") as LOCALE_IDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLayout(layout: Layout) {
|
setLayout(layout: Layout) {
|
||||||
@@ -577,7 +582,6 @@ export class AppContext extends Component {
|
|||||||
|
|
||||||
this.tabManager.loadTabs();
|
this.tabManager.loadTabs();
|
||||||
|
|
||||||
const bundleService = (await import("../services/bundle.js")).default;
|
|
||||||
setTimeout(() => bundleService.executeStartupBundles(), 2000);
|
setTimeout(() => bundleService.executeStartupBundles(), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
|
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
|
||||||
|
|
||||||
import bundleService from "../services/bundle.js";
|
import bundleService from "../services/bundle.js";
|
||||||
|
import dialog from "../services/dialog.js";
|
||||||
import dateNoteService from "../services/date_notes.js";
|
import dateNoteService from "../services/date_notes.js";
|
||||||
import froca from "../services/froca.js";
|
import froca from "../services/froca.js";
|
||||||
import { t } from "../services/i18n.js";
|
import { t } from "../services/i18n.js";
|
||||||
@@ -216,4 +217,21 @@ export default class Entrypoints extends Component {
|
|||||||
|
|
||||||
toastService.showMessage(t("entrypoints.note-revision-created"));
|
toastService.showMessage(t("entrypoints.note-revision-created"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async saveNamedRevisionCommand() {
|
||||||
|
const noteId = appContext.tabManager.getActiveContextNoteId();
|
||||||
|
if (!noteId) return;
|
||||||
|
|
||||||
|
const name = await dialog.prompt({
|
||||||
|
title: t("entrypoints.save-named-revision-title"),
|
||||||
|
message: t("entrypoints.save-named-revision-message"),
|
||||||
|
defaultValue: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
// null means the user cancelled
|
||||||
|
if (name === null) return;
|
||||||
|
|
||||||
|
await server.post(`notes/${noteId}/revision`, { description: name || undefined });
|
||||||
|
toastService.showMessage(t("entrypoints.note-revision-created"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ export type GetTextEditorCallback = (editor: CKTextEditor) => void;
|
|||||||
|
|
||||||
export type SaveState = "saved" | "saving" | "unsaved" | "error";
|
export type SaveState = "saved" | "saving" | "unsaved" | "error";
|
||||||
|
|
||||||
|
const READ_ONLY_CAPABLE_TYPES: string[] = [
|
||||||
|
"text",
|
||||||
|
"code",
|
||||||
|
"mermaid",
|
||||||
|
"canvas",
|
||||||
|
"mindMap",
|
||||||
|
"spreadsheet"
|
||||||
|
];
|
||||||
|
|
||||||
export interface NoteContextDataMap {
|
export interface NoteContextDataMap {
|
||||||
toc: HeadingContext;
|
toc: HeadingContext;
|
||||||
pdfPages: {
|
pdfPages: {
|
||||||
@@ -303,8 +312,12 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "readOnly" is a state valid only for text/code notes
|
if (!this.note) {
|
||||||
if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) {
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note types that support a read-only state (via the #readOnly label, source view, or auto-readonly).
|
||||||
|
if (!READ_ONLY_CAPABLE_TYPES.includes(this.note.type)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +333,11 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto read-only based on content size is only configurable for text/code.
|
||||||
|
if (this.note.type !== "text" && this.note.type !== "code") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Store the initial decision about read-only status in the viewScope
|
// Store the initial decision about read-only status in the viewScope
|
||||||
// This will be "remembered" until the viewScope is refreshed
|
// This will be "remembered" until the viewScope is refreshed
|
||||||
if (!this.viewScope) {
|
if (!this.viewScope) {
|
||||||
|
|||||||
@@ -148,6 +148,19 @@ export default class RootCommandExecutor extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async showNoteOCRTextCommand() {
|
||||||
|
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||||
|
|
||||||
|
if (notePath) {
|
||||||
|
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
|
||||||
|
activate: true,
|
||||||
|
viewScope: {
|
||||||
|
viewMode: "ocr"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async showAttachmentsCommand() {
|
async showAttachmentsCommand() {
|
||||||
const notePath = appContext.tabManager.getActiveContextNotePath();
|
const notePath = appContext.tabManager.getActiveContextNotePath();
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function initOnElectron() {
|
|||||||
const currentWindow = electronRemote.getCurrentWindow();
|
const currentWindow = electronRemote.getCurrentWindow();
|
||||||
const style = window.getComputedStyle(document.body);
|
const style = window.getComputedStyle(document.body);
|
||||||
|
|
||||||
initDarkOrLightMode(style);
|
initDarkOrLightMode();
|
||||||
initTransparencyEffects(style, currentWindow);
|
initTransparencyEffects(style, currentWindow);
|
||||||
initFullScreenDetection(currentWindow);
|
initFullScreenDetection(currentWindow);
|
||||||
|
|
||||||
@@ -119,11 +119,11 @@ function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Elec
|
|||||||
*
|
*
|
||||||
* @param style the root CSS element to read variables from.
|
* @param style the root CSS element to read variables from.
|
||||||
*/
|
*/
|
||||||
function initDarkOrLightMode(style: CSSStyleDeclaration) {
|
function initDarkOrLightMode() {
|
||||||
let themeSource: typeof nativeTheme.themeSource = "system";
|
let themeSource: typeof nativeTheme.themeSource = "system";
|
||||||
|
|
||||||
const themeStyle = style.getPropertyValue("--theme-style");
|
const themeStyle = window.glob.getThemeStyle();
|
||||||
if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) {
|
if (themeStyle !== "auto") {
|
||||||
themeSource = themeStyle;
|
themeSource = themeStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,15 @@ class FAttribute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get isAutoLink() {
|
get isAutoLink() {
|
||||||
return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
|
if (this.type === "relation") {
|
||||||
|
return ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.type === "label") {
|
||||||
|
return this.name === "internalBookmark";
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get toString() {
|
get toString() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getNoteIcon } from "@triliumnext/commons";
|
import { getNoteIcon } from "@triliumnext/commons";
|
||||||
|
|
||||||
|
import bundleService from "../services/bundle.js";
|
||||||
import cssClassManager from "../services/css_class_manager.js";
|
import cssClassManager from "../services/css_class_manager.js";
|
||||||
import type { Froca } from "../services/froca-interface.js";
|
import type { Froca } from "../services/froca-interface.js";
|
||||||
import noteAttributeCache from "../services/note_attribute_cache.js";
|
import noteAttributeCache from "../services/note_attribute_cache.js";
|
||||||
@@ -18,7 +19,7 @@ const RELATION = "relation";
|
|||||||
* end user. Those types should be used only for checking against, they are
|
* end user. Those types should be used only for checking against, they are
|
||||||
* not for direct use.
|
* not for direct use.
|
||||||
*/
|
*/
|
||||||
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
|
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet" | "llmChat";
|
||||||
|
|
||||||
export interface NotePathRecord {
|
export interface NotePathRecord {
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
@@ -235,6 +236,16 @@ export default class FNote {
|
|||||||
return this.hasAttribute("label", "archived");
|
return this.hasAttribute("label", "archived");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the note's metadata (title, icon) should not be editable.
|
||||||
|
* This applies to system notes like options, help, and launch bar configuration.
|
||||||
|
*/
|
||||||
|
get isMetadataReadOnly() {
|
||||||
|
return utils.isLaunchBarConfig(this.noteId)
|
||||||
|
|| this.noteId.startsWith("_help_")
|
||||||
|
|| this.noteId.startsWith("_options");
|
||||||
|
}
|
||||||
|
|
||||||
getChildNoteIds() {
|
getChildNoteIds() {
|
||||||
return this.children;
|
return this.children;
|
||||||
}
|
}
|
||||||
@@ -1014,7 +1025,6 @@ export default class FNote {
|
|||||||
const env = this.getScriptEnv();
|
const env = this.getScriptEnv();
|
||||||
|
|
||||||
if (env === "frontend") {
|
if (env === "frontend") {
|
||||||
const bundleService = (await import("../services/bundle.js")).default;
|
|
||||||
return await bundleService.getAndExecuteBundle(this.noteId);
|
return await bundleService.getAndExecuteBundle(this.noteId);
|
||||||
} else if (env === "backend") {
|
} else if (env === "backend") {
|
||||||
await server.post(`script/run/${this.noteId}`);
|
await server.post(`script/run/${this.noteId}`);
|
||||||
@@ -1059,6 +1069,10 @@ export default class FNote {
|
|||||||
return this.mime === "text/x-sqlite;schema=trilium";
|
return this.mime === "text/x-sqlite;schema=trilium";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isMarkdown() {
|
||||||
|
return this.type === "code" && (this.mime === "text/markdown" || this.mime === "text/x-markdown" || this.mime === "text/x-gfm");
|
||||||
|
}
|
||||||
|
|
||||||
isTriliumScript() {
|
isTriliumScript() {
|
||||||
return this.mime.startsWith("application/javascript");
|
return this.mime.startsWith("application/javascript");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getThemeStyle } from "./services/theme";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
showSplash();
|
showSplash();
|
||||||
await setupGlob();
|
await setupGlob();
|
||||||
@@ -36,8 +38,36 @@ async function setupGlob() {
|
|||||||
window.global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
window.global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||||
window.glob = {
|
window.glob = {
|
||||||
...json,
|
...json,
|
||||||
activeDialog: null
|
activeDialog: null,
|
||||||
|
device: json.device || getDevice()
|
||||||
};
|
};
|
||||||
|
window.glob.getThemeStyle = getThemeStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDevice() {
|
||||||
|
// Respect user's manual override via URL.
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
if (urlParams.has("print")) {
|
||||||
|
return "print";
|
||||||
|
} else if (urlParams.has("desktop")) {
|
||||||
|
return "desktop";
|
||||||
|
} else if (urlParams.has("mobile")) {
|
||||||
|
return "mobile";
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceCookie = document.cookie.split("; ").find(row => row.startsWith("trilium-device="))?.split("=")[1];
|
||||||
|
if (deviceCookie === "desktop" || deviceCookie === "mobile") return deviceCookie;
|
||||||
|
return isMobile() ? "mobile" : "desktop";
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/73731646/944162
|
||||||
|
function isMobile() {
|
||||||
|
const mQ = matchMedia?.("(pointer:coarse)");
|
||||||
|
if (mQ?.media === "(pointer:coarse)") return !!mQ.matches;
|
||||||
|
|
||||||
|
if ("orientation" in window) return true;
|
||||||
|
const userAgentsRegEx = /\b(Android|iPhone|iPad|iPod|Windows Phone|BlackBerry|webOS|IEMobile)\b/i;
|
||||||
|
return userAgentsRegEx.test(navigator.userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBootstrapCss() {
|
async function loadBootstrapCss() {
|
||||||
@@ -49,31 +79,65 @@ async function loadBootstrapCss() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStylesheets() {
|
type StylesheetRef = {
|
||||||
const { device, assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
|
href: string;
|
||||||
|
media?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const cssToLoad: string[] = [];
|
function getConfiguredThemeStylesheets(stylesheetsPath: string, theme: string, customThemeCssUrl?: string) {
|
||||||
if (device !== "print") {
|
if (theme === "auto") {
|
||||||
cssToLoad.push(`${assetPath}/stylesheets/ckeditor-theme.css`);
|
return [{ href: `${stylesheetsPath}/theme-dark.css`, media: "(prefers-color-scheme: dark)" }];
|
||||||
cssToLoad.push(`api/fonts`);
|
|
||||||
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) {
|
if (theme === "dark") {
|
||||||
|
return [{ href: `${stylesheetsPath}/theme-dark.css` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme === "next") {
|
||||||
|
return [
|
||||||
|
{ href: `${stylesheetsPath}/theme-next-light.css` },
|
||||||
|
{ href: `${stylesheetsPath}/theme-next-dark.css`, media: "(prefers-color-scheme: dark)" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme === "next-light") {
|
||||||
|
return [{ href: `${stylesheetsPath}/theme-next-light.css` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme === "next-dark") {
|
||||||
|
return [{ href: `${stylesheetsPath}/theme-next-dark.css` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme !== "light" && customThemeCssUrl) {
|
||||||
|
return [{ href: customThemeCssUrl }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStylesheets() {
|
||||||
|
const { device, assetPath, theme, themeBase, customThemeCssUrl } = window.glob;
|
||||||
|
const stylesheetsPath = `${assetPath}/stylesheets`;
|
||||||
|
|
||||||
|
const cssToLoad: StylesheetRef[] = [];
|
||||||
|
if (device !== "print") {
|
||||||
|
cssToLoad.push({ href: `${stylesheetsPath}/ckeditor-theme.css` });
|
||||||
|
cssToLoad.push({ href: `api/fonts` });
|
||||||
|
cssToLoad.push({ href: `${stylesheetsPath}/theme-light.css` });
|
||||||
|
cssToLoad.push(...getConfiguredThemeStylesheets(stylesheetsPath, theme, customThemeCssUrl));
|
||||||
|
if (themeBase) {
|
||||||
|
cssToLoad.push(...getConfiguredThemeStylesheets(stylesheetsPath, themeBase));
|
||||||
|
}
|
||||||
|
cssToLoad.push({ href: `${stylesheetsPath}/style.css` });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { href, media } of cssToLoad) {
|
||||||
const linkEl = document.createElement("link");
|
const linkEl = document.createElement("link");
|
||||||
linkEl.href = href;
|
linkEl.href = href;
|
||||||
linkEl.rel = "stylesheet";
|
linkEl.rel = "stylesheet";
|
||||||
|
if (media) {
|
||||||
|
linkEl.media = media;
|
||||||
|
}
|
||||||
document.head.appendChild(linkEl);
|
document.head.appendChild(linkEl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +149,8 @@ function loadIcons() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setBodyAttributes() {
|
function setBodyAttributes() {
|
||||||
|
if (!glob.dbInitialized) return;
|
||||||
|
|
||||||
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
|
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
|
||||||
const classesToSet = [
|
const classesToSet = [
|
||||||
device,
|
device,
|
||||||
@@ -105,6 +171,11 @@ function setBodyAttributes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadScripts() {
|
async function loadScripts() {
|
||||||
|
if (!glob.dbInitialized) {
|
||||||
|
await import("./setup.js");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (glob.device) {
|
switch (glob.device) {
|
||||||
case "mobile":
|
case "mobile":
|
||||||
await import("./mobile.js");
|
await import("./mobile.js");
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
|
|||||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||||
|
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar.jsx";
|
||||||
import StatusBar from "../widgets/layout/StatusBar.jsx";
|
import StatusBar from "../widgets/layout/StatusBar.jsx";
|
||||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||||
@@ -90,6 +91,7 @@ export default class DesktopLayout {
|
|||||||
.optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />)
|
.optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />)
|
||||||
.child(<TabHistoryNavigationButtons />)
|
.child(<TabHistoryNavigationButtons />)
|
||||||
.child(new TabRowWidget().class("full-width"))
|
.child(new TabRowWidget().class("full-width"))
|
||||||
|
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||||
.optChild(isNewLayout, <RightPaneToggle />)
|
.optChild(isNewLayout, <RightPaneToggle />)
|
||||||
.optChild(customTitleBarButtons, <TitleBarButtons />)
|
.optChild(customTitleBarButtons, <TitleBarButtons />)
|
||||||
.css("height", "40px")
|
.css("height", "40px")
|
||||||
@@ -117,6 +119,7 @@ export default class DesktopLayout {
|
|||||||
.class("tab-row-container")
|
.class("tab-row-container")
|
||||||
.child(<TabHistoryNavigationButtons />)
|
.child(<TabHistoryNavigationButtons />)
|
||||||
.child(new TabRowWidget())
|
.child(new TabRowWidget())
|
||||||
|
.optChild(glob.isStandalone, <StandaloneWarningBar />)
|
||||||
.optChild(isNewLayout, <RightPaneToggle />)
|
.optChild(isNewLayout, <RightPaneToggle />)
|
||||||
.optChild(customTitleBarButtons, <TitleBarButtons />)
|
.optChild(customTitleBarButtons, <TitleBarButtons />)
|
||||||
.css("height", "40px")
|
.css("height", "40px")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import InfoDialog from "../widgets/dialogs/info.js";
|
|||||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||||
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
|
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
|
||||||
|
import PrintPreviewDialog from "../widgets/dialogs/print_preview.jsx";
|
||||||
import ToastContainer from "../widgets/Toast.jsx";
|
import ToastContainer from "../widgets/Toast.jsx";
|
||||||
|
|
||||||
export function applyModals(rootContainer: RootContainer) {
|
export function applyModals(rootContainer: RootContainer) {
|
||||||
@@ -51,6 +52,7 @@ export function applyModals(rootContainer: RootContainer) {
|
|||||||
.child(<PromptDialog />)
|
.child(<PromptDialog />)
|
||||||
.child(<IncorrectCpuArchDialog />)
|
.child(<IncorrectCpuArchDialog />)
|
||||||
.child(<PopupEditorDialog />)
|
.child(<PopupEditorDialog />)
|
||||||
|
.child(<PrintPreviewDialog />)
|
||||||
.child(<CallToActionDialog />)
|
.child(<CallToActionDialog />)
|
||||||
.child(<ToastContainer />);
|
.child(<ToastContainer />);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
|||||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||||
|
import StandaloneWarningBar from "../widgets/layout/StandaloneWarningBar";
|
||||||
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
|
||||||
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
|
||||||
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
|
||||||
@@ -23,6 +24,7 @@ import NoteTreeWidget from "../widgets/note_tree.js";
|
|||||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||||
|
import { isMobileApp } from "../services/utils";
|
||||||
import ScrollPadding from "../widgets/scroll_padding";
|
import ScrollPadding from "../widgets/scroll_padding";
|
||||||
import SearchResult from "../widgets/search_result.jsx";
|
import SearchResult from "../widgets/search_result.jsx";
|
||||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||||
@@ -64,6 +66,8 @@ export default class MobileLayout {
|
|||||||
.child(<NoteIconWidget />)
|
.child(<NoteIconWidget />)
|
||||||
.child(<NoteTitleWidget />)
|
.child(<NoteTitleWidget />)
|
||||||
.child(<NoteBadges />)
|
.child(<NoteBadges />)
|
||||||
|
.optChild(isMobileApp(), <StandaloneWarningBar variant="mobile" />)
|
||||||
|
.optChild(glob.isStandalone && !isMobileApp(), <StandaloneWarningBar />)
|
||||||
.child(<MobileDetailMenu />)
|
.child(<MobileDetailMenu />)
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface MenuCommandItem<T> {
|
|||||||
title: string;
|
title: string;
|
||||||
command?: T;
|
command?: T;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
mime?: string;
|
||||||
/**
|
/**
|
||||||
* The icon to display in the menu item.
|
* The icon to display in the menu item.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import utils from "../services/utils.js";
|
|
||||||
import options from "../services/options.js";
|
|
||||||
import zoomService from "../components/zoom.js";
|
|
||||||
import contextMenu, { type MenuItem } from "./context_menu.js";
|
|
||||||
import { t } from "../services/i18n.js";
|
|
||||||
import server from "../services/server.js";
|
|
||||||
import * as clipboardExt from "../services/clipboard_ext.js";
|
|
||||||
import type { BrowserWindow } from "electron";
|
import type { BrowserWindow } from "electron";
|
||||||
import type { CommandNames, AppContext } from "../components/app_context.js";
|
|
||||||
|
import type { CommandNames } from "../components/app_context.js";
|
||||||
|
import appContext from "../components/app_context.js";
|
||||||
|
import zoomService from "../components/zoom.js";
|
||||||
|
import * as clipboardExt from "../services/clipboard_ext.js";
|
||||||
|
import { t } from "../services/i18n.js";
|
||||||
|
import options from "../services/options.js";
|
||||||
|
import server from "../services/server.js";
|
||||||
|
import utils from "../services/utils.js";
|
||||||
|
import contextMenu, { type MenuItem } from "./context_menu.js";
|
||||||
|
|
||||||
function setupContextMenu() {
|
function setupContextMenu() {
|
||||||
const electron = utils.dynamicRequire("electron");
|
const electron = utils.dynamicRequire("electron");
|
||||||
@@ -15,8 +17,6 @@ function setupContextMenu() {
|
|||||||
// FIXME: Remove typecast once Electron is properly integrated.
|
// FIXME: Remove typecast once Electron is properly integrated.
|
||||||
const { webContents } = remote.getCurrentWindow() as BrowserWindow;
|
const { webContents } = remote.getCurrentWindow() as BrowserWindow;
|
||||||
|
|
||||||
let appContext: AppContext;
|
|
||||||
|
|
||||||
webContents.on("context-menu", (event, params) => {
|
webContents.on("context-menu", (event, params) => {
|
||||||
const { editFlags } = params;
|
const { editFlags } = params;
|
||||||
const hasText = params.selectionText.trim().length > 0;
|
const hasText = params.selectionText.trim().length > 0;
|
||||||
@@ -38,7 +38,7 @@ function setupContextMenu() {
|
|||||||
items.push({
|
items.push({
|
||||||
title: t("electron_context_menu.add-term-to-dictionary", { term: params.misspelledWord }),
|
title: t("electron_context_menu.add-term-to-dictionary", { term: params.misspelledWord }),
|
||||||
uiIcon: "bx bx-plus",
|
uiIcon: "bx bx-plus",
|
||||||
handler: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
handler: () => electron.ipcRenderer.send("add-word-to-dictionary", params.misspelledWord)
|
||||||
});
|
});
|
||||||
|
|
||||||
items.push({ kind: "separator" });
|
items.push({ kind: "separator" });
|
||||||
@@ -141,7 +141,7 @@ function setupContextMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Replace the placeholder with the real search keyword.
|
// Replace the placeholder with the real search keyword.
|
||||||
let searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
|
const searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
|
||||||
|
|
||||||
items.push({ kind: "separator" });
|
items.push({ kind: "separator" });
|
||||||
|
|
||||||
@@ -155,10 +155,6 @@ function setupContextMenu() {
|
|||||||
title: t("electron_context_menu.search_in_trilium", { term: shortenedSelection }),
|
title: t("electron_context_menu.search_in_trilium", { term: shortenedSelection }),
|
||||||
uiIcon: "bx bx-search",
|
uiIcon: "bx bx-search",
|
||||||
handler: async () => {
|
handler: async () => {
|
||||||
if (!appContext) {
|
|
||||||
appContext = (await import("../components/app_context.js")).default;
|
|
||||||
}
|
|
||||||
|
|
||||||
await appContext.triggerCommand("searchNotes", {
|
await appContext.triggerCommand("searchNotes", {
|
||||||
searchString: params.selectionText
|
searchString: params.selectionText
|
||||||
});
|
});
|
||||||
|
|||||||
101
apps/client/src/menus/launcher_button_context_menu.ts
Normal file
101
apps/client/src/menus/launcher_button_context_menu.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ToggleInParentResponse } from "@triliumnext/commons";
|
||||||
|
|
||||||
|
import type FNote from "../entities/fnote.js";
|
||||||
|
import branchService from "../services/branches.js";
|
||||||
|
import { t } from "../services/i18n.js";
|
||||||
|
import server from "../services/server.js";
|
||||||
|
import toast from "../services/toast.js";
|
||||||
|
import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js";
|
||||||
|
|
||||||
|
const VISIBLE_LAUNCHER_PARENTS = ["_lbVisibleLaunchers", "_lbMobileVisibleLaunchers"];
|
||||||
|
|
||||||
|
function getVisibleLauncherBranch(launcherNote: FNote) {
|
||||||
|
return launcherNote.getParentBranches().find((b) => VISIBLE_LAUNCHER_PARENTS.includes(b.parentNoteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBookmarkBranch(launcherNote: FNote) {
|
||||||
|
return launcherNote.getParentBranches().find((b) => b.parentNoteId === "_lbBookmarks");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFromLaunchBar(launcherNote: FNote) {
|
||||||
|
const bookmarkBranch = getBookmarkBranch(launcherNote);
|
||||||
|
if (bookmarkBranch) {
|
||||||
|
// Individual bookmarks are represented via a branch under `_lbBookmarks`; removing them
|
||||||
|
// from the launch bar is the same as unbookmarking the note.
|
||||||
|
const resp = await server.put<ToggleInParentResponse>(
|
||||||
|
`notes/${launcherNote.noteId}/toggle-in-parent/_lbBookmarks/false`
|
||||||
|
);
|
||||||
|
if (!resp.success && resp.message) {
|
||||||
|
toast.showError(resp.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const launcherBranch = getVisibleLauncherBranch(launcherNote);
|
||||||
|
if (!launcherBranch) return;
|
||||||
|
|
||||||
|
const isMobileLauncher = launcherBranch.parentNoteId === "_lbMobileVisibleLaunchers";
|
||||||
|
// Branch IDs in the hidden subtree follow the `${parentNoteId}_${noteId}` convention,
|
||||||
|
// so the branch linking `_lb(Mobile)?Root` to the "available" launchers root is predictable.
|
||||||
|
const targetBranchId = isMobileLauncher
|
||||||
|
? "_lbMobileRoot__lbMobileAvailableLaunchers"
|
||||||
|
: "_lbRoot__lbAvailableLaunchers";
|
||||||
|
await branchService.moveToParentNote([launcherBranch.branchId], targetBranchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRemoveFromLaunchBar(launcherNote: FNote | null | undefined) {
|
||||||
|
if (!launcherNote) return false;
|
||||||
|
return !!(getVisibleLauncherBranch(launcherNote) || getBookmarkBranch(launcherNote));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowLauncherContextMenuOptions<T extends string> {
|
||||||
|
/** Menu items specific to this launcher (e.g. "Open in new tab" for note-based launchers). They appear above the "Remove from launch bar" item. */
|
||||||
|
extraItems?: MenuItem<T>[];
|
||||||
|
/** Handler for the {@link extraItems}. The "Remove from launch bar" item is handled internally and will not be forwarded. */
|
||||||
|
onCommand?: (command: T | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMOVE_COMMAND = "__removeFromLaunchBar__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the launch bar icon context menu. When the launcher can be removed (i.e. it is a direct
|
||||||
|
* child of the visible launchers root or of `_lbBookmarks`), a "Remove from launch bar" entry is
|
||||||
|
* appended. Extra items can be supplied to preserve launcher-specific actions (e.g. "Open in new tab").
|
||||||
|
*/
|
||||||
|
export async function showLauncherContextMenu<T extends string>(
|
||||||
|
launcherNote: FNote | null | undefined,
|
||||||
|
e: ContextMenuEvent,
|
||||||
|
options: ShowLauncherContextMenuOptions<T> = {}
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const items = [...(options.extraItems ?? [])] as MenuItem<string>[];
|
||||||
|
|
||||||
|
if (canRemoveFromLaunchBar(launcherNote)) {
|
||||||
|
if (items.length > 0) {
|
||||||
|
items.push({ kind: "separator" });
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
title: t("launcher_button_context_menu.remove_from_launch_bar"),
|
||||||
|
command: REMOVE_COMMAND,
|
||||||
|
uiIcon: "bx bx-x-circle"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
contextMenu.show<string>({
|
||||||
|
x: e.pageX ?? 0,
|
||||||
|
y: e.pageY ?? 0,
|
||||||
|
items,
|
||||||
|
selectMenuItemHandler: ({ command }) => {
|
||||||
|
if (command === REMOVE_COMMAND) {
|
||||||
|
if (launcherNote) {
|
||||||
|
void removeFromLaunchBar(launcherNote);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.onCommand?.(command as T | undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -288,7 +288,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
|||||||
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
|
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
|
async selectMenuItemHandler({ command, type, mime, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
|
||||||
const notePath = treeService.getNotePath(this.node);
|
const notePath = treeService.getNotePath(this.node);
|
||||||
|
|
||||||
if (utils.isMobile()) {
|
if (utils.isMobile()) {
|
||||||
@@ -305,6 +305,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
|||||||
target: "after",
|
target: "after",
|
||||||
targetBranchId: this.node.data.branchId,
|
targetBranchId: this.node.data.branchId,
|
||||||
type,
|
type,
|
||||||
|
mime,
|
||||||
isProtected,
|
isProtected,
|
||||||
templateNoteId
|
templateNoteId
|
||||||
});
|
});
|
||||||
@@ -313,6 +314,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
|||||||
|
|
||||||
noteCreateService.createNote(parentNotePath, {
|
noteCreateService.createNote(parentNotePath, {
|
||||||
type,
|
type,
|
||||||
|
mime,
|
||||||
isProtected: this.node.data.isProtected,
|
isProtected: this.node.data.isProtected,
|
||||||
templateNoteId
|
templateNoteId
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,17 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--print-font-size: 11pt;
|
--print-font-size: 11pt;
|
||||||
--ck-content-color-image-caption-background: transparent !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
--print-font-family: var(--detail-font-family, sans-serif);
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: black;
|
color: black;
|
||||||
}
|
font-family: var(--print-font-family);
|
||||||
|
|
||||||
@page {
|
|
||||||
margin: 2cm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list-widget.full-height,
|
.note-list-widget.full-height,
|
||||||
@@ -26,6 +25,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body[data-note-type="text"] .ck-content {
|
body[data-note-type="text"] .ck-content {
|
||||||
|
--ck-content-font-family: var(--print-font-family);
|
||||||
|
--ck-content-font-size: var(--print-font-size);
|
||||||
|
--ck-content-font-color: black;
|
||||||
|
--ck-content-line-height: 1.5;
|
||||||
|
--ck-content-color-image-caption-background: transparent;
|
||||||
|
|
||||||
font-size: var(--print-font-size);
|
font-size: var(--print-font-size);
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
}
|
}
|
||||||
@@ -154,4 +159,4 @@ span[style] {
|
|||||||
.page-break::after {
|
.page-break::after {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useLayoutEffect, useRef } from "preact/hooks";
|
|||||||
import FNote from "./entities/fnote";
|
import FNote from "./entities/fnote";
|
||||||
import content_renderer from "./services/content_renderer";
|
import content_renderer from "./services/content_renderer";
|
||||||
import { applyInlineMermaid } from "./services/content_renderer_text";
|
import { applyInlineMermaid } from "./services/content_renderer_text";
|
||||||
|
import froca from "./services/froca";
|
||||||
import { dynamicRequire, isElectron } from "./services/utils";
|
import { dynamicRequire, isElectron } from "./services/utils";
|
||||||
import { CustomNoteList, useNoteViewType } from "./widgets/collections/NoteList";
|
import { CustomNoteList, useNoteViewType } from "./widgets/collections/NoteList";
|
||||||
|
|
||||||
@@ -30,7 +31,21 @@ async function main() {
|
|||||||
if (!noteId) return;
|
if (!noteId) return;
|
||||||
|
|
||||||
await import("./print.css");
|
await import("./print.css");
|
||||||
const froca = (await import("./services/froca")).default;
|
|
||||||
|
// Browser printing relies on @page margins since there's no programmatic control.
|
||||||
|
// Electron uses printToPDF() margins instead, so we only inject this for the browser path.
|
||||||
|
if (!isElectron()) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = "@page { margin: 2cm; }";
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the user's font preferences so that --detail-font-family is available.
|
||||||
|
const fontLink = document.createElement("link");
|
||||||
|
fontLink.rel = "stylesheet";
|
||||||
|
fontLink.href = "api/fonts";
|
||||||
|
document.head.appendChild(fontLink);
|
||||||
|
|
||||||
const note = await froca.getNote(noteId);
|
const note = await froca.getNote(noteId);
|
||||||
|
|
||||||
const bodyWrapper = document.createElement("div");
|
const bodyWrapper = document.createElement("div");
|
||||||
@@ -105,6 +120,9 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
|
|||||||
|
|
||||||
// Check custom CSS.
|
// Check custom CSS.
|
||||||
await loadCustomCss(note);
|
await loadCustomCss(note);
|
||||||
|
|
||||||
|
// Wait for all fonts (including those from custom CSS) to finish loading.
|
||||||
|
await document.fonts.ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
load().then(() => requestAnimationFrame(() => onReady({
|
load().then(() => requestAnimationFrame(() => onReady({
|
||||||
@@ -130,6 +148,7 @@ function CollectionRenderer({ note, onReady, onProgressChanged }: RendererProps)
|
|||||||
media="print"
|
media="print"
|
||||||
onReady={async (data: PrintReport) => {
|
onReady={async (data: PrintReport) => {
|
||||||
await loadCustomCss(note);
|
await loadCustomCss(note);
|
||||||
|
await document.fonts.ready;
|
||||||
onReady(data);
|
onReady(data);
|
||||||
}}
|
}}
|
||||||
onProgressChanged={onProgressChanged}
|
onProgressChanged={onProgressChanged}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ async function renderAttribute(attribute: FAttribute, renderIsInheritable: boole
|
|||||||
const isInheritable = renderIsInheritable && attribute.isInheritable ? `(inheritable)` : "";
|
const isInheritable = renderIsInheritable && attribute.isInheritable ? `(inheritable)` : "";
|
||||||
const $attr = $("<span>");
|
const $attr = $("<span>");
|
||||||
|
|
||||||
|
if (attribute.isAutoLink) {
|
||||||
|
return $attr;
|
||||||
|
}
|
||||||
|
|
||||||
if (attribute.type === "label") {
|
if (attribute.type === "label") {
|
||||||
$attr.append(document.createTextNode(`#${attribute.name}${isInheritable}`));
|
$attr.append(document.createTextNode(`#${attribute.name}${isInheritable}`));
|
||||||
|
|
||||||
@@ -15,9 +19,6 @@ async function renderAttribute(attribute: FAttribute, renderIsInheritable: boole
|
|||||||
$attr.append(document.createTextNode(formatValue(attribute.value)));
|
$attr.append(document.createTextNode(formatValue(attribute.value)));
|
||||||
}
|
}
|
||||||
} else if (attribute.type === "relation") {
|
} else if (attribute.type === "relation") {
|
||||||
if (attribute.isAutoLink) {
|
|
||||||
return $attr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// when the relation has just been created, then it might not have a value
|
// when the relation has just been created, then it might not have a value
|
||||||
if (attribute.value) {
|
if (attribute.value) {
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import froca from "./froca";
|
|||||||
import server from "./server.js";
|
import server from "./server.js";
|
||||||
|
|
||||||
// Spy on server methods to track calls
|
// Spy on server methods to track calls
|
||||||
// @ts-expect-error the generic typing is causing issues here
|
server.put = vi.fn(async () => ({})) as typeof server.put;
|
||||||
server.put = vi.fn(async <T> (url: string, data?: T) => ({} as T));
|
server.remove = vi.fn(async () => ({})) as typeof server.remove;
|
||||||
// @ts-expect-error the generic typing is causing issues here
|
|
||||||
server.remove = vi.fn(async <T> (url: string) => ({} as T));
|
|
||||||
|
|
||||||
describe("Set boolean with inheritance", () => {
|
describe("Set boolean with inheritance", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
|
|||||||
|
|
||||||
if (moveToParent) {
|
if (moveToParent) {
|
||||||
try {
|
try {
|
||||||
await activateParentNotePath();
|
await activateParentNotePath(branchIdsToDelete);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
@@ -152,13 +152,28 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateParentNotePath() {
|
async function activateParentNotePath(branchIdsToDelete: string[]) {
|
||||||
// this is not perfect, maybe we should find the next/previous sibling, but that's more complex
|
|
||||||
const activeContext = appContext.tabManager.getActiveContext();
|
const activeContext = appContext.tabManager.getActiveContext();
|
||||||
const parentNotePathArr = activeContext?.notePathArray.slice(0, -1);
|
const activeNotePath = activeContext?.notePathArray ?? [];
|
||||||
|
|
||||||
if (parentNotePathArr && parentNotePathArr.length > 0) {
|
// Find the deleted branch that appears earliest in the active note's path
|
||||||
activeContext?.setNote(parentNotePathArr.join("/"));
|
let earliestIndex = activeNotePath.length;
|
||||||
|
for (const branchId of branchIdsToDelete) {
|
||||||
|
const branch = froca.getBranch(branchId);
|
||||||
|
if (branch) {
|
||||||
|
const index = activeNotePath.indexOf(branch.noteId);
|
||||||
|
if (index !== -1 && index < earliestIndex) {
|
||||||
|
earliestIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the parent of the highest deleted ancestor
|
||||||
|
if (earliestIndex < activeNotePath.length) {
|
||||||
|
const parentPath = activeNotePath.slice(0, earliestIndex);
|
||||||
|
if (parentPath.length > 0) {
|
||||||
|
await activeContext?.setNote(parentPath.join("/"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { ScriptParams } from "@triliumnext/commons";
|
||||||
import { h, VNode } from "preact";
|
import { h, VNode } from "preact";
|
||||||
|
|
||||||
|
import FNote from "../entities/fnote.js";
|
||||||
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||||
import RightPanelWidget from "../widgets/right_panel_widget.js";
|
import RightPanelWidget from "../widgets/right_panel_widget.js";
|
||||||
import type { Entity } from "./frontend_script_api.js";
|
import type { Entity } from "./frontend_script_api.js";
|
||||||
@@ -26,7 +28,7 @@ type WithNoteId<T> = T & {
|
|||||||
};
|
};
|
||||||
export type Widget = WithNoteId<(LegacyWidget | WidgetDefinitionWithType)>;
|
export type Widget = WithNoteId<(LegacyWidget | WidgetDefinitionWithType)>;
|
||||||
|
|
||||||
async function getAndExecuteBundle(noteId: string, originEntity = null, script = null, params = null) {
|
async function getAndExecuteBundle(noteId: string, originEntity: FNote | null = null, script: string | null = null, params: ScriptParams | null = null) {
|
||||||
const bundle = await server.post<Bundle>(`script/bundle/${noteId}`, {
|
const bundle = await server.post<Bundle>(`script/bundle/${noteId}`, {
|
||||||
script,
|
script,
|
||||||
params
|
params
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { t } from "./i18n.js";
|
||||||
|
import toast from "./toast.js";
|
||||||
|
|
||||||
export function copyText(text: string) {
|
export function copyText(text: string) {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
@@ -6,29 +9,26 @@ export function copyText(text: string) {
|
|||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
return true;
|
return true;
|
||||||
} else {
|
}
|
||||||
// Fallback method: https://stackoverflow.com/a/72239825
|
// Fallback method: https://stackoverflow.com/a/72239825
|
||||||
const textArea = document.createElement("textarea");
|
const textArea = document.createElement("textarea");
|
||||||
textArea.value = text;
|
textArea.value = text;
|
||||||
try {
|
try {
|
||||||
document.body.appendChild(textArea);
|
document.body.appendChild(textArea);
|
||||||
textArea.focus();
|
textArea.focus();
|
||||||
textArea.select();
|
textArea.select();
|
||||||
return document.execCommand('copy');
|
return document.execCommand('copy');
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyTextWithToast(text: string) {
|
export function copyTextWithToast(text: string) {
|
||||||
const t = (await import("./i18n.js")).t;
|
|
||||||
const toast = (await import("./toast.js")).default;
|
|
||||||
|
|
||||||
if (copyText(text)) {
|
if (copyText(text)) {
|
||||||
toast.showMessage(t("clipboard.copy_success"));
|
toast.showMessage(t("clipboard.copy_success"));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "./content_renderer.css";
|
import "./content_renderer.css";
|
||||||
|
|
||||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
|
import { normalizeMimeTypeForCKEditor, renderToHtml, type TextRepresentationResponse } from "@triliumnext/commons";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
import { h, render } from "preact";
|
import { h, render } from "preact";
|
||||||
import WheelZoom from 'vanilla-js-wheel-zoom';
|
import WheelZoom from 'vanilla-js-wheel-zoom';
|
||||||
|
|
||||||
@@ -8,13 +9,14 @@ import FAttachment from "../entities/fattachment.js";
|
|||||||
import FNote from "../entities/fnote.js";
|
import FNote from "../entities/fnote.js";
|
||||||
import imageContextMenuService from "../menus/image_context_menu.js";
|
import imageContextMenuService from "../menus/image_context_menu.js";
|
||||||
import { t } from "../services/i18n.js";
|
import { t } from "../services/i18n.js";
|
||||||
import renderText from "./content_renderer_text.js";
|
import renderText, { postProcessRichContent, renderChildrenList } from "./content_renderer_text.js";
|
||||||
import renderDoc from "./doc_renderer.js";
|
import renderDoc from "./doc_renderer.js";
|
||||||
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
|
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
|
||||||
import openService from "./open.js";
|
import openService from "./open.js";
|
||||||
import protectedSessionService from "./protected_session.js";
|
import protectedSessionService from "./protected_session.js";
|
||||||
import protectedSessionHolder from "./protected_session_holder.js";
|
import protectedSessionHolder from "./protected_session_holder.js";
|
||||||
import renderService from "./render.js";
|
import renderService from "./render.js";
|
||||||
|
import server from "./server.js";
|
||||||
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
|
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
|
||||||
import utils, { getErrorMessage } from "./utils.js";
|
import utils, { getErrorMessage } from "./utils.js";
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export interface RenderOptions {
|
|||||||
includeArchivedNotes?: boolean;
|
includeArchivedNotes?: boolean;
|
||||||
/** Set of note IDs that have already been seen during rendering to prevent infinite recursion. */
|
/** Set of note IDs that have already been seen during rendering to prevent infinite recursion. */
|
||||||
seenNoteIds?: Set<string>;
|
seenNoteIds?: Set<string>;
|
||||||
|
showTextRepresentation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CODE_MIME_TYPES = new Set(["application/json"]);
|
const CODE_MIME_TYPES = new Set(["application/json"]);
|
||||||
@@ -52,12 +55,14 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
|||||||
|
|
||||||
if (type === "text" || type === "book") {
|
if (type === "text" || type === "book") {
|
||||||
await renderText(entity, $renderedContent, options);
|
await renderText(entity, $renderedContent, options);
|
||||||
|
} else if (type === "markdown") {
|
||||||
|
await renderMarkdown(entity, $renderedContent, options);
|
||||||
} else if (type === "code") {
|
} else if (type === "code") {
|
||||||
await renderCode(entity, $renderedContent);
|
await renderCode(entity, $renderedContent);
|
||||||
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
|
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
|
||||||
renderImage(entity, $renderedContent, options);
|
await renderImage(entity, $renderedContent, options);
|
||||||
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
|
||||||
await renderFile(entity, type, $renderedContent);
|
await renderFile(entity, type, $renderedContent, options);
|
||||||
} else if (type === "mermaid") {
|
} else if (type === "mermaid") {
|
||||||
await renderMermaid(entity, $renderedContent);
|
await renderMermaid(entity, $renderedContent);
|
||||||
} else if (type === "render" && entity instanceof FNote) {
|
} else if (type === "render" && entity instanceof FNote) {
|
||||||
@@ -117,6 +122,31 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a markdown note by converting its source to CKEditor-compatible HTML,
|
||||||
|
* then running the same post-render pipeline as text notes (included notes,
|
||||||
|
* math, reference links, Mermaid, code highlight) so the preview matches what
|
||||||
|
* the user sees in the Markdown note type's preview pane.
|
||||||
|
*/
|
||||||
|
async function renderMarkdown(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions) {
|
||||||
|
const blob = await note.getBlob();
|
||||||
|
const source = blob?.content ?? "";
|
||||||
|
|
||||||
|
if (!source.trim()) {
|
||||||
|
if (note instanceof FNote && !options.noChildrenList) {
|
||||||
|
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = renderToHtml(source, note.title, {
|
||||||
|
sanitize: (dirty) => DOMPurify.sanitize(dirty),
|
||||||
|
wikiLink: { formatHref: (id) => `#root/${id}` }
|
||||||
|
});
|
||||||
|
$renderedContent.append($('<div class="ck-content">').html(html));
|
||||||
|
await postProcessRichContent(note, $renderedContent, options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type.
|
* Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type.
|
||||||
*/
|
*/
|
||||||
@@ -138,7 +168,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
|
|||||||
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
|
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
async function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
||||||
const encodedTitle = encodeURIComponent(entity.title);
|
const encodedTitle = encodeURIComponent(entity.title);
|
||||||
|
|
||||||
let url;
|
let url;
|
||||||
@@ -146,13 +176,14 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
|
|||||||
if (entity instanceof FNote) {
|
if (entity instanceof FNote) {
|
||||||
url = `api/images/${entity.noteId}/${encodedTitle}?${Math.random()}`;
|
url = `api/images/${entity.noteId}/${encodedTitle}?${Math.random()}`;
|
||||||
} else if (entity instanceof FAttachment) {
|
} else if (entity instanceof FAttachment) {
|
||||||
url = `api/attachments/${entity.attachmentId}/image/${encodedTitle}?${entity.utcDateModified}">`;
|
url = `api/attachments/${entity.attachmentId}/image/${encodedTitle}?${entity.utcDateModified}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
$renderedContent // styles needed for the zoom to work well
|
$renderedContent // styles needed for the zoom to work well
|
||||||
.css("display", "flex")
|
.css("display", "flex")
|
||||||
.css("align-items", "center")
|
.css("align-items", "center")
|
||||||
.css("justify-content", "center");
|
.css("justify-content", "center")
|
||||||
|
.css("flex-direction", "column"); // OCR text is displayed below the image.
|
||||||
|
|
||||||
const $img = $("<img>")
|
const $img = $("<img>")
|
||||||
.attr("src", url || "")
|
.attr("src", url || "")
|
||||||
@@ -178,9 +209,35 @@ function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLE
|
|||||||
}
|
}
|
||||||
|
|
||||||
imageContextMenuService.setupContextMenu($img);
|
imageContextMenuService.setupContextMenu($img);
|
||||||
|
|
||||||
|
if (entity instanceof FNote && options.showTextRepresentation) {
|
||||||
|
await addOCRTextIfAvailable(entity, $renderedContent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>) {
|
async function addOCRTextIfAvailable(note: FNote, $content: JQuery<HTMLElement>) {
|
||||||
|
try {
|
||||||
|
const data = await server.get<TextRepresentationResponse>(`ocr/notes/${note.noteId}/text`);
|
||||||
|
if (data.success && data.hasOcr && data.text) {
|
||||||
|
const $ocrSection = $(`
|
||||||
|
<div class="ocr-text-section">
|
||||||
|
<div class="ocr-header">
|
||||||
|
<span class="bx bx-text"></span> ${t("ocr.extracted_text")}
|
||||||
|
</div>
|
||||||
|
<div class="ocr-content"></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$ocrSection.find('.ocr-content').text(data.text);
|
||||||
|
$content.append($ocrSection);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail if OCR API is not available
|
||||||
|
console.debug('Failed to fetch OCR text:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
||||||
let entityType, entityId;
|
let entityType, entityId;
|
||||||
|
|
||||||
if (entity instanceof FNote) {
|
if (entity instanceof FNote) {
|
||||||
@@ -220,6 +277,10 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
|
|||||||
$content.append($videoPreview);
|
$content.append($videoPreview);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entity instanceof FNote && options.showTextRepresentation) {
|
||||||
|
await addOCRTextIfAvailable(entity, $content);
|
||||||
|
}
|
||||||
|
|
||||||
if (entityType === "notes" && "noteId" in entity) {
|
if (entityType === "notes" && "noteId" in entity) {
|
||||||
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
|
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
|
||||||
// in attachment list
|
// in attachment list
|
||||||
@@ -297,6 +358,8 @@ function getRenderingType(entity: FNote | FAttachment) {
|
|||||||
|
|
||||||
if (type === "file" && mime === "application/pdf") {
|
if (type === "file" && mime === "application/pdf") {
|
||||||
type = "pdf";
|
type = "pdf";
|
||||||
|
} else if (type === "code" && entity instanceof FNote && entity.isMarkdown()) {
|
||||||
|
type = "markdown";
|
||||||
} else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime) && !isIconPack) {
|
} else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime) && !isIconPack) {
|
||||||
type = "code";
|
type = "code";
|
||||||
} else if (type === "file" && mime && mime.startsWith("audio/")) {
|
} else if (type === "file" && mime && mime.startsWith("audio/")) {
|
||||||
|
|||||||
@@ -15,37 +15,47 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
|
|||||||
|
|
||||||
if (blob && !isHtmlEmpty(blob.content)) {
|
if (blob && !isHtmlEmpty(blob.content)) {
|
||||||
$renderedContent.append($('<div class="ck-content">').html(blob.content));
|
$renderedContent.append($('<div class="ck-content">').html(blob.content));
|
||||||
|
await postProcessRichContent(note, $renderedContent, options);
|
||||||
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
|
|
||||||
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
|
|
||||||
if (!options.noIncludedNotes) {
|
|
||||||
await renderIncludedNotes($renderedContent[0], seenNoteIds);
|
|
||||||
} else {
|
|
||||||
$renderedContent.find("section.include-note").remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($renderedContent.find("span.math-tex").length > 0) {
|
|
||||||
renderMathInElement($renderedContent[0], { trust: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
|
|
||||||
const referenceLinks = $renderedContent.find<HTMLAnchorElement>("a.reference-link");
|
|
||||||
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
|
|
||||||
await froca.getNotes(noteIdsToPrefetch);
|
|
||||||
|
|
||||||
for (const el of referenceLinks) {
|
|
||||||
const innerSpan = document.createElement("span");
|
|
||||||
await link.loadReferenceLinkTitle($(innerSpan), el.href);
|
|
||||||
el.replaceChildren(innerSpan);
|
|
||||||
}
|
|
||||||
|
|
||||||
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
|
|
||||||
await formatCodeBlocks($renderedContent);
|
|
||||||
} else if (note instanceof FNote && !options.noChildrenList) {
|
} else if (note instanceof FNote && !options.noChildrenList) {
|
||||||
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
|
await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the post-render passes that make CKEditor-compatible HTML fully
|
||||||
|
* interactive: expand `<section class="include-note">`, render inline math and
|
||||||
|
* Mermaid diagrams, rewrite reference-link titles, and highlight code blocks.
|
||||||
|
* Assumes the caller has already appended the HTML inside a `.ck-content` child
|
||||||
|
* of `$renderedContent`.
|
||||||
|
*/
|
||||||
|
export async function postProcessRichContent(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
|
||||||
|
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
|
||||||
|
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
|
||||||
|
if (!options.noIncludedNotes) {
|
||||||
|
await renderIncludedNotes($renderedContent[0], seenNoteIds);
|
||||||
|
} else {
|
||||||
|
$renderedContent.find("section.include-note").remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($renderedContent.find("span.math-tex").length > 0) {
|
||||||
|
renderMathInElement($renderedContent[0], { trust: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
|
||||||
|
const referenceLinks = $renderedContent.find<HTMLAnchorElement>("a.reference-link");
|
||||||
|
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
|
||||||
|
await froca.getNotes(noteIdsToPrefetch);
|
||||||
|
|
||||||
|
await Promise.all(referenceLinks.toArray().map(async (el) => {
|
||||||
|
const innerSpan = document.createElement("span");
|
||||||
|
await link.loadReferenceLinkTitle($(innerSpan), el.getAttribute("href"));
|
||||||
|
el.replaceChildren(innerSpan);
|
||||||
|
}));
|
||||||
|
|
||||||
|
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
|
||||||
|
await formatCodeBlocks($renderedContent);
|
||||||
|
}
|
||||||
|
|
||||||
async function renderIncludedNotes(contentEl: HTMLElement, seenNoteIds: Set<string>) {
|
async function renderIncludedNotes(contentEl: HTMLElement, seenNoteIds: Set<string>) {
|
||||||
// TODO: Consider duplicating with server's share/content_renderer.ts.
|
// TODO: Consider duplicating with server's share/content_renderer.ts.
|
||||||
const includeNoteEls = contentEl.querySelectorAll("section.include-note");
|
const includeNoteEls = contentEl.querySelectorAll("section.include-note");
|
||||||
@@ -101,19 +111,107 @@ export async function rewriteMermaidDiagramsInContainer(container: HTMLDivElemen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-container cache of rendered mermaid SVG keyed by diagram source text.
|
||||||
|
* Populated after each successful render; reused on subsequent renders to
|
||||||
|
* avoid flicker when the preview HTML is regenerated (e.g. live markdown
|
||||||
|
* editing). Entries for diagrams no longer present in the container are
|
||||||
|
* evicted on each run so the cache can't grow unbounded.
|
||||||
|
*/
|
||||||
|
const mermaidSvgCache = new WeakMap<HTMLElement, Map<string, string>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-container, ordered snapshot of the most recently rendered SVGs. Used as
|
||||||
|
* a positional placeholder so edits to a diagram's source keep the previous
|
||||||
|
* SVG visible while the new one renders offscreen.
|
||||||
|
*/
|
||||||
|
const mermaidLastRenderedByPosition = new WeakMap<HTMLElement, string[]>();
|
||||||
|
|
||||||
export async function applyInlineMermaid(container: HTMLDivElement) {
|
export async function applyInlineMermaid(container: HTMLDivElement) {
|
||||||
// Initialize mermaid
|
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
|
||||||
|
if (!nodes.length) {
|
||||||
|
mermaidLastRenderedByPosition.delete(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache = mermaidSvgCache.get(container);
|
||||||
|
if (!cache) {
|
||||||
|
cache = new Map();
|
||||||
|
mermaidSvgCache.set(container, cache);
|
||||||
|
}
|
||||||
|
const lastRendered = mermaidLastRenderedByPosition.get(container) ?? [];
|
||||||
|
|
||||||
|
// Decide per node: exact cache hit → paint final SVG; source changed →
|
||||||
|
// paint the previous SVG (by position) as a placeholder and queue an
|
||||||
|
// offscreen re-render. This way the user keeps seeing the old diagram
|
||||||
|
// until mermaid has finished producing the new one.
|
||||||
|
const pending: Array<{ visible: HTMLElement; source: string }> = [];
|
||||||
|
const seenSources = new Set<string>();
|
||||||
|
for (const [ index, node ] of nodes.entries()) {
|
||||||
|
const source = (node.textContent ?? "").trim();
|
||||||
|
seenSources.add(source);
|
||||||
|
|
||||||
|
const cached = cache.get(source);
|
||||||
|
if (cached) {
|
||||||
|
node.innerHTML = cached;
|
||||||
|
node.setAttribute("data-processed", "true");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.push({ visible: node, source });
|
||||||
|
const placeholder = lastRendered[index];
|
||||||
|
if (placeholder) {
|
||||||
|
node.innerHTML = placeholder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict cache entries whose source is no longer present.
|
||||||
|
for (const key of [ ...cache.keys() ]) {
|
||||||
|
if (!seenSources.has(key)) cache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pending.length) {
|
||||||
|
mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const mermaid = (await import("mermaid")).default;
|
const mermaid = (await import("mermaid")).default;
|
||||||
mermaid.initialize(getMermaidConfig());
|
mermaid.initialize(getMermaidConfig());
|
||||||
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
|
|
||||||
|
// Render clones offscreen so the visible nodes keep showing the placeholder
|
||||||
|
// until the new SVG is ready. Keeps mermaid away from our placeholder SVG
|
||||||
|
// (which would otherwise confuse its text-based parser).
|
||||||
|
const offscreen = document.createElement("div");
|
||||||
|
offscreen.style.cssText = "position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;visibility:hidden;";
|
||||||
|
document.body.appendChild(offscreen);
|
||||||
|
|
||||||
|
const pairs = pending.map(({ visible, source }) => {
|
||||||
|
const clone = document.createElement("div");
|
||||||
|
clone.className = "mermaid-diagram";
|
||||||
|
clone.textContent = source;
|
||||||
|
offscreen.appendChild(clone);
|
||||||
|
return { visible, clone, source };
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mermaid.run({ nodes });
|
await mermaid.run({ nodes: pairs.map((p) => p.clone) });
|
||||||
|
for (const { visible, clone, source } of pairs) {
|
||||||
|
if (clone.getAttribute("data-processed") !== "true") continue;
|
||||||
|
const svg = clone.innerHTML;
|
||||||
|
visible.innerHTML = svg;
|
||||||
|
visible.setAttribute("data-processed", "true");
|
||||||
|
cache.set(source, svg);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
offscreen.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote, includeArchivedNotes: boolean) {
|
export async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote, includeArchivedNotes: boolean) {
|
||||||
let childNoteIds = note.getChildNoteIds();
|
let childNoteIds = note.getChildNoteIds();
|
||||||
|
|
||||||
if (!childNoteIds.length) {
|
if (!childNoteIds.length) {
|
||||||
|
|||||||
@@ -84,6 +84,55 @@ async function createSearchNote(opts = {}) {
|
|||||||
return await froca.getNote(note.noteId);
|
return await froca.getNote(note.noteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createLlmChat() {
|
||||||
|
const note = await server.post<FNoteRow>("special-notes/llm-chat");
|
||||||
|
|
||||||
|
await ws.waitForMaxKnownEntityChangeId();
|
||||||
|
|
||||||
|
return await froca.getNote(note.noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the most recently modified LLM chat.
|
||||||
|
* Returns null if no chat exists.
|
||||||
|
*/
|
||||||
|
async function getMostRecentLlmChat() {
|
||||||
|
const note = await server.get<FNoteRow | null>("special-notes/most-recent-llm-chat");
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ws.waitForMaxKnownEntityChangeId();
|
||||||
|
|
||||||
|
return await froca.getNote(note.noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the most recent LLM chat, or creates a new one if none exists.
|
||||||
|
* Used by sidebar chat for persistent conversations across page refreshes.
|
||||||
|
*/
|
||||||
|
async function getOrCreateLlmChat() {
|
||||||
|
const note = await server.get<FNoteRow>("special-notes/get-or-create-llm-chat");
|
||||||
|
|
||||||
|
await ws.waitForMaxKnownEntityChangeId();
|
||||||
|
|
||||||
|
return await froca.getNote(note.noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentLlmChat {
|
||||||
|
noteId: string;
|
||||||
|
title: string;
|
||||||
|
dateModified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a list of recent LLM chats for the history popup.
|
||||||
|
*/
|
||||||
|
async function getRecentLlmChats(limit: number = 10): Promise<RecentLlmChat[]> {
|
||||||
|
return await server.get<RecentLlmChat[]>(`special-notes/recent-llm-chats?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getInboxNote,
|
getInboxNote,
|
||||||
getTodayNote,
|
getTodayNote,
|
||||||
@@ -94,5 +143,9 @@ export default {
|
|||||||
getMonthNote,
|
getMonthNote,
|
||||||
getYearNote,
|
getYearNote,
|
||||||
createSqlConsole,
|
createSqlConsole,
|
||||||
createSearchNote
|
createSearchNote,
|
||||||
|
createLlmChat,
|
||||||
|
getMostRecentLlmChat,
|
||||||
|
getOrCreateLlmChat,
|
||||||
|
getRecentLlmChats
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Modal } from "bootstrap";
|
import { Modal } from "bootstrap";
|
||||||
|
|
||||||
import appContext from "../components/app_context.js";
|
import appContext from "../components/app_context.js";
|
||||||
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
|
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
|
||||||
|
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
||||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||||
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
||||||
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
import keyboardActionsService from "./keyboard_actions.js";
|
||||||
|
|
||||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
|
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
|
||||||
if (closeActDialog) {
|
if (closeActDialog) {
|
||||||
@@ -25,7 +27,6 @@ export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const keyboardActionsService = (await import("./keyboard_actions.js")).default;
|
|
||||||
keyboardActionsService.updateDisplayedShortcuts($dialog);
|
keyboardActionsService.updateDisplayedShortcuts($dialog);
|
||||||
|
|
||||||
return $dialog;
|
return $dialog;
|
||||||
|
|||||||
30
apps/client/src/services/doc_renderer.spec.ts
Normal file
30
apps/client/src/services/doc_renderer.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isValidDocName } from "./doc_renderer.js";
|
||||||
|
|
||||||
|
describe("isValidDocName", () => {
|
||||||
|
it("accepts valid docNames", () => {
|
||||||
|
expect(isValidDocName("launchbar_intro")).toBe(true);
|
||||||
|
expect(isValidDocName("User Guide/Quick Start")).toBe(true);
|
||||||
|
expect(isValidDocName("User Guide/User Guide/Quick Start")).toBe(true);
|
||||||
|
expect(isValidDocName("Quick Start Guide")).toBe(true);
|
||||||
|
expect(isValidDocName("quick_start_guide")).toBe(true);
|
||||||
|
expect(isValidDocName("quick-start-guide")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects path traversal attacks", () => {
|
||||||
|
expect(isValidDocName("..")).toBe(false);
|
||||||
|
expect(isValidDocName("../etc/passwd")).toBe(false);
|
||||||
|
expect(isValidDocName("foo/../bar")).toBe(false);
|
||||||
|
expect(isValidDocName("../../../../api/notes/_malicious/open")).toBe(false);
|
||||||
|
expect(isValidDocName("..\\etc\\passwd")).toBe(false);
|
||||||
|
expect(isValidDocName("foo\\bar")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects URL manipulation attacks", () => {
|
||||||
|
expect(isValidDocName("../../../../api/notes/_malicious/open?x=")).toBe(false);
|
||||||
|
expect(isValidDocName("foo#bar")).toBe(false);
|
||||||
|
expect(isValidDocName("%2e%2e")).toBe(false);
|
||||||
|
expect(isValidDocName("%2e%2e%2f%2e%2e%2fapi")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,22 +3,39 @@ import { applyReferenceLinks } from "../widgets/type_widgets/text/read_only_help
|
|||||||
import { getCurrentLanguage } from "./i18n.js";
|
import { getCurrentLanguage } from "./i18n.js";
|
||||||
import { formatCodeBlocks } from "./syntax_highlight.js";
|
import { formatCodeBlocks } from "./syntax_highlight.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a docName to prevent path traversal attacks.
|
||||||
|
* Allows forward slashes for subdirectories (e.g., "User Guide/Quick Start")
|
||||||
|
* but blocks traversal sequences and URL manipulation characters.
|
||||||
|
*/
|
||||||
|
export function isValidDocName(docName: string): boolean {
|
||||||
|
// Allow alphanumeric characters, spaces, underscores, hyphens, and forward slashes.
|
||||||
|
const validDocNameRegex = /^[a-zA-Z0-9_/\- ]+$/;
|
||||||
|
return validDocNameRegex.test(docName);
|
||||||
|
}
|
||||||
|
|
||||||
export default function renderDoc(note: FNote) {
|
export default function renderDoc(note: FNote) {
|
||||||
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
||||||
let docName = note.getLabelValue("docName");
|
const docName = note.getLabelValue("docName");
|
||||||
const $content = $("<div>");
|
const $content = $("<div>");
|
||||||
|
|
||||||
if (docName) {
|
// find doc based on language
|
||||||
// find doc based on language
|
const url = getUrl(docName, getCurrentLanguage());
|
||||||
const url = getUrl(docName, getCurrentLanguage());
|
|
||||||
|
if (url) {
|
||||||
$content.load(url, async (response, status) => {
|
$content.load(url, async (response, status) => {
|
||||||
// fallback to english doc if no translation available
|
// fallback to english doc if no translation available
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
const fallbackUrl = getUrl(docName, "en");
|
const fallbackUrl = getUrl(docName, "en");
|
||||||
$content.load(fallbackUrl, async () => {
|
|
||||||
await processContent(fallbackUrl, $content)
|
if (fallbackUrl) {
|
||||||
|
$content.load(fallbackUrl, async () => {
|
||||||
|
await processContent(fallbackUrl, $content);
|
||||||
|
resolve($content);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
resolve($content);
|
resolve($content);
|
||||||
});
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +45,6 @@ export default function renderDoc(note: FNote) {
|
|||||||
} else {
|
} else {
|
||||||
resolve($content);
|
resolve($content);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $content;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +52,9 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
|||||||
const dir = url.substring(0, url.lastIndexOf("/"));
|
const dir = url.substring(0, url.lastIndexOf("/"));
|
||||||
|
|
||||||
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
||||||
$content.find("img").each((i, el) => {
|
$content.find("img").each((_i, el) => {
|
||||||
const $img = $(el);
|
const $img = $(el);
|
||||||
$img.attr("src", dir + "/" + $img.attr("src"));
|
$img.attr("src", `${dir}/${$img.attr("src")}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
formatCodeBlocks($content);
|
formatCodeBlocks($content);
|
||||||
@@ -48,10 +63,27 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
|||||||
await applyReferenceLinks($content[0]);
|
await applyReferenceLinks($content[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUrl(docNameValue: string, language: string) {
|
function getUrl(docNameValue: string | null, language: string) {
|
||||||
|
if (!docNameValue) return;
|
||||||
|
|
||||||
|
if (!isValidDocName(docNameValue)) {
|
||||||
|
console.error(`Invalid docName: ${docNameValue}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||||
|
// The user guide is available only in English, so make sure we are requesting correctly since 404s in standalone client are treated differently.
|
||||||
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
|
if (docNameValue.includes("User%20Guide")) language = "en";
|
||||||
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
|
return `${getBasePath()}/doc_notes/${language}/${docNameValue}.html`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBasePath() {
|
||||||
|
if (window.glob.isStandalone) {
|
||||||
|
return `server-assets`;
|
||||||
|
}
|
||||||
|
if (window.glob.isDev) {
|
||||||
|
return `${window.glob.assetPath}/..`;
|
||||||
|
}
|
||||||
|
return window.glob.assetPath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import options from "./options";
|
import options from "./options";
|
||||||
import { isMobile } from "./utils";
|
import { isMobile, isStandalone } from "./utils";
|
||||||
|
|
||||||
export interface ExperimentalFeature {
|
export interface ExperimentalFeature {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,11 +13,21 @@ export const experimentalFeatures = [
|
|||||||
id: "new-layout",
|
id: "new-layout",
|
||||||
name: t("experimental_features.new_layout_name"),
|
name: t("experimental_features.new_layout_name"),
|
||||||
description: t("experimental_features.new_layout_description"),
|
description: t("experimental_features.new_layout_description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "llm",
|
||||||
|
name: t("experimental_features.llm_name"),
|
||||||
|
description: t("experimental_features.llm_description"),
|
||||||
}
|
}
|
||||||
] as const satisfies ExperimentalFeature[];
|
] as const satisfies ExperimentalFeature[];
|
||||||
|
|
||||||
export type ExperimentalFeatureId = typeof experimentalFeatures[number]["id"];
|
export type ExperimentalFeatureId = typeof experimentalFeatures[number]["id"];
|
||||||
|
|
||||||
|
/** Returns experimental features available for the current platform (excludes LLM in standalone mode). */
|
||||||
|
export function getAvailableExperimentalFeatures() {
|
||||||
|
return experimentalFeatures.filter(f => !(f.id === "llm" && isStandalone));
|
||||||
|
}
|
||||||
|
|
||||||
let enabledFeatures: Set<ExperimentalFeatureId> | null = null;
|
let enabledFeatures: Set<ExperimentalFeatureId> | null = null;
|
||||||
|
|
||||||
export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean {
|
export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean {
|
||||||
@@ -25,14 +35,24 @@ export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId):
|
|||||||
return (isMobile() || options.is("newLayout"));
|
return (isMobile() || options.is("newLayout"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LLM features require server-side API calls that don't work in standalone mode
|
||||||
|
// due to CORS restrictions from LLM providers (OpenAI, Google don't allow browser requests)
|
||||||
|
if (featureId === "llm" && isStandalone) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return getEnabledFeatures().has(featureId);
|
return getEnabledFeatures().has(featureId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEnabledExperimentalFeatureIds() {
|
export function getEnabledExperimentalFeatureIds() {
|
||||||
const values = [ ...getEnabledFeatures().values() ];
|
let values = [ ...getEnabledFeatures().values() ];
|
||||||
if (isMobile() || options.is("newLayout")) {
|
if (isMobile() || options.is("newLayout")) {
|
||||||
values.push("new-layout");
|
values.push("new-layout");
|
||||||
}
|
}
|
||||||
|
// LLM is not available in standalone mode
|
||||||
|
if (isStandalone) {
|
||||||
|
values = values.filter(v => v !== "llm");
|
||||||
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import appContext from "../components/app_context.js";
|
||||||
|
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
|
||||||
|
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
|
||||||
|
import FBlob, { type FBlobRow } from "../entities/fblob.js";
|
||||||
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
|
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
|
||||||
import FNote, { type FNoteRow } from "../entities/fnote.js";
|
import FNote, { type FNoteRow } from "../entities/fnote.js";
|
||||||
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
|
|
||||||
import server from "./server.js";
|
|
||||||
import appContext from "../components/app_context.js";
|
|
||||||
import FBlob, { type FBlobRow } from "../entities/fblob.js";
|
|
||||||
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
|
|
||||||
import type { Froca } from "./froca-interface.js";
|
import type { Froca } from "./froca-interface.js";
|
||||||
|
import server from "./server.js";
|
||||||
|
|
||||||
interface SubtreeResponse {
|
interface SubtreeResponse {
|
||||||
notes: FNoteRow[];
|
notes: FNoteRow[];
|
||||||
@@ -44,8 +44,9 @@ class FrocaImpl implements Froca {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadInitialTree() {
|
async loadInitialTree() {
|
||||||
const resp = await server.get<SubtreeResponse>("tree");
|
if (!glob.dbInitialized) return;
|
||||||
|
|
||||||
|
const resp = await server.get<SubtreeResponse>("tree");
|
||||||
// clear the cache only directly before adding new content which is important for e.g., switching to protected session
|
// clear the cache only directly before adding new content which is important for e.g., switching to protected session
|
||||||
this.#clear();
|
this.#clear();
|
||||||
this.addResp(resp);
|
this.addResp(resp);
|
||||||
@@ -77,7 +78,7 @@ class FrocaImpl implements Froca {
|
|||||||
for (const noteRow of noteRows) {
|
for (const noteRow of noteRows) {
|
||||||
const { noteId } = noteRow;
|
const { noteId } = noteRow;
|
||||||
|
|
||||||
let note = this.notes[noteId];
|
const note = this.notes[noteId];
|
||||||
|
|
||||||
if (note) {
|
if (note) {
|
||||||
note.update(noteRow);
|
note.update(noteRow);
|
||||||
@@ -240,9 +241,8 @@ class FrocaImpl implements Froca {
|
|||||||
console.trace(`Can't find note '${noteId}'`);
|
console.trace(`Can't find note '${noteId}'`);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} else {
|
|
||||||
return this.notes[noteId];
|
|
||||||
}
|
}
|
||||||
|
return this.notes[noteId];
|
||||||
})
|
})
|
||||||
.filter((note) => !!note) as FNote[];
|
.filter((note) => !!note) as FNote[];
|
||||||
}
|
}
|
||||||
@@ -263,9 +263,8 @@ class FrocaImpl implements Froca {
|
|||||||
console.trace(`Can't find note '${noteId}'`);
|
console.trace(`Can't find note '${noteId}'`);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} else {
|
|
||||||
return this.notes[noteId];
|
|
||||||
}
|
}
|
||||||
|
return this.notes[noteId];
|
||||||
})
|
})
|
||||||
.filter((note) => !!note) as FNote[];
|
.filter((note) => !!note) as FNote[];
|
||||||
}
|
}
|
||||||
@@ -338,11 +337,10 @@ class FrocaImpl implements Froca {
|
|||||||
attachmentRows = await server.getWithSilentNotFound<FAttachmentRow[]>(`attachments/${attachmentId}/all`);
|
attachmentRows = await server.getWithSilentNotFound<FAttachmentRow[]>(`attachments/${attachmentId}/all`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (silentNotFoundError) {
|
if (silentNotFoundError) {
|
||||||
logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ` + e.message);
|
logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ${e.message}`);
|
||||||
return null;
|
return null;
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachments = this.processAttachmentRows(attachmentRows);
|
const attachments = this.processAttachmentRows(attachmentRows);
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import LoadResults from "./load_results.js";
|
import type { OptionNames } from "@triliumnext/commons";
|
||||||
import froca from "./froca.js";
|
|
||||||
import utils from "./utils.js";
|
import appContext from "../components/app_context.js";
|
||||||
import options from "./options.js";
|
|
||||||
import noteAttributeCache from "./note_attribute_cache.js";
|
|
||||||
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
|
|
||||||
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
|
|
||||||
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
|
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
|
||||||
|
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
|
||||||
|
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
|
||||||
import type { default as FNote, FNoteRow } from "../entities/fnote.js";
|
import type { default as FNote, FNoteRow } from "../entities/fnote.js";
|
||||||
import type { EntityChange } from "../server_types.js";
|
import type { EntityChange } from "../server_types.js";
|
||||||
import type { OptionNames } from "@triliumnext/commons";
|
import froca from "./froca.js";
|
||||||
|
import LoadResults from "./load_results.js";
|
||||||
|
import noteAttributeCache from "./note_attribute_cache.js";
|
||||||
|
import options from "./options.js";
|
||||||
|
import utils from "./utils.js";
|
||||||
|
|
||||||
async function processEntityChanges(entityChanges: EntityChange[]) {
|
async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||||
const loadResults = new LoadResults(entityChanges);
|
const loadResults = new LoadResults(entityChanges);
|
||||||
@@ -63,7 +65,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
|||||||
if (entityName === "branches" && !((entity as FBranchRow).parentNoteId in froca.notes)) {
|
if (entityName === "branches" && !((entity as FBranchRow).parentNoteId in froca.notes)) {
|
||||||
missingNoteIds.push((entity as FBranchRow).parentNoteId);
|
missingNoteIds.push((entity as FBranchRow).parentNoteId);
|
||||||
} else if (entityName === "attributes") {
|
} else if (entityName === "attributes") {
|
||||||
let attributeEntity = entity as FAttributeRow;
|
const attributeEntity = entity as FAttributeRow;
|
||||||
if (attributeEntity.type === "relation" && (attributeEntity.name === "template" || attributeEntity.name === "inherit") && !(attributeEntity.value in froca.notes)) {
|
if (attributeEntity.type === "relation" && (attributeEntity.name === "template" || attributeEntity.name === "inherit") && !(attributeEntity.value in froca.notes)) {
|
||||||
missingNoteIds.push(attributeEntity.value);
|
missingNoteIds.push(attributeEntity.value);
|
||||||
}
|
}
|
||||||
@@ -79,7 +81,6 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
|||||||
noteAttributeCache.invalidate();
|
noteAttributeCache.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
const appContext = (await import("../components/app_context.js")).default;
|
|
||||||
await appContext.triggerEvent("entitiesReloaded", { loadResults });
|
await appContext.triggerEvent("entitiesReloaded", { loadResults });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export interface Api {
|
|||||||
* Instance name identifies particular Trilium instance. It can be useful for scripts
|
* Instance name identifies particular Trilium instance. It can be useful for scripts
|
||||||
* if some action needs to happen on only one specific instance.
|
* if some action needs to happen on only one specific instance.
|
||||||
*/
|
*/
|
||||||
getInstanceName(): string;
|
getInstanceName(): string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns date in YYYY-MM-DD format
|
* @returns date in YYYY-MM-DD format
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, h, VNode } from "preact";
|
import { createContext, Fragment, h, VNode } from "preact";
|
||||||
import * as hooks from "preact/hooks";
|
import * as hooks from "preact/hooks";
|
||||||
|
|
||||||
import ActionButton from "../widgets/react/ActionButton";
|
import ActionButton from "../widgets/react/ActionButton";
|
||||||
@@ -47,6 +47,7 @@ export const preactAPI = Object.freeze({
|
|||||||
// Core
|
// Core
|
||||||
h,
|
h,
|
||||||
Fragment,
|
Fragment,
|
||||||
|
createContext,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method that must be run for widget scripts that run on Preact, using JSX. The method just returns the same definition, reserved for future typechecking and perhaps validation purposes.
|
* Method that must be run for widget scripts that run on Preact, using JSX. The method just returns the same definition, reserved for future typechecking and perhaps validation purposes.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user