From 297ad75e94a14cddfe6fe69b85cd2220da9a50ee Mon Sep 17 00:00:00 2001 From: Alex Dachin Date: Sat, 16 Aug 2025 20:14:28 +0200 Subject: [PATCH 1/4] Allow external blobs --- apps/server/spec/db/document.db | Bin 1220608 -> 1228800 bytes apps/server/spec/etapi/external-blobs.spec.ts | 94 +++++++++++++ apps/server/src/assets/db/schema.sql | 2 + apps/server/src/becca/becca-interface.ts | 4 +- .../becca/entities/abstract_becca_entity.ts | 49 ++++++- apps/server/src/becca/entities/bblob.ts | 9 +- apps/server/src/becca/entities/bnote.ts | 13 +- apps/server/src/becca/entities/brevision.ts | 7 +- apps/server/src/migrations/migrations.ts | 13 ++ apps/server/src/routes/api/revisions.ts | 7 +- apps/server/src/routes/api/stats.ts | 13 +- apps/server/src/services/app_info.ts | 2 +- apps/server/src/services/blob-interface.ts | 4 +- apps/server/src/services/blob-storage.ts | 128 ++++++++++++++++++ apps/server/src/services/blob.ts | 25 +++- apps/server/src/services/config.ts | 110 +++++++++------ apps/server/src/services/entity_changes.ts | 6 +- apps/server/src/services/erase.ts | 23 +++- .../expressions/note_content_fulltext.ts | 100 +++++++------- .../src/services/search/services/search.ts | 61 +++++---- apps/server/src/services/sync.ts | 17 ++- apps/server/src/services/ws.ts | 7 +- apps/server/src/share/content_renderer.ts | 2 +- .../src/share/shaca/entities/sattachment.ts | 9 +- apps/server/src/share/shaca/entities/snote.ts | 9 +- apps/server/vite.config.mts | 3 +- packages/commons/src/lib/rows.ts | 5 +- 27 files changed, 559 insertions(+), 163 deletions(-) create mode 100644 apps/server/spec/etapi/external-blobs.spec.ts create mode 100644 apps/server/src/services/blob-storage.ts diff --git a/apps/server/spec/db/document.db b/apps/server/spec/db/document.db index 264a9ff0d1edf2d8b2df3eefedec5ba881e48ffe..a9340595c70266de54fd2a2b14b7e21a30bbc2ab 100644 GIT binary patch delta 7868 zcmb_B2~?9;y8pfZz9%69qGH@PkRIzTMe(M9082)n7wCq z@asm<^|}?bjUhBT*g^?Ag(m(8t27as-5 z$hI5=p|!VVRV=P9%5+wh7$ec($H2!>KY_k`48(y>5D}&MSP|fI_4q4>DcLZ?A zOFj~HSExFKOVQBfa332skr|$Vx+9c9=&j?L3Ex}}8H1mGo? z!4;ZUxVO=w3*r!DJ!`O_n;#mC0)f5I$h6j?_LFiP3flsAj#5U@NwBcdQb=&;2GOBC z-y1`nl@(>K(8`ih51sz&TU#K$wS}fTIl)662ip-}z}TSBfno}n)8wb%WTGJ0i)J{P zg%gmlO7M7f74EDk2~VBh;B@CknooHl5ec))iZe@d7FOFAnBT)koXj6M?>!??V>uJ4 zSr#LkbN zl`?x_Ze&r9dFC#4lq@U>chAbL7dp|XdM3(fpvM4TIyYKU&rFDl%v)AdRhu_EwJb8q zxU}ctym&|Y{CazeqrBEs?S(|AM7qLL@=C3d3nTopWriA!kJU3%CGCB%2a0)OqG%T_ zqKcLbT*P)U!x=SSKZ8bhF^}U{yO@Iv*o$jEV*bwe1Vun>Q&MX4 z?55-jcmCYPm3Jb`{Abj9kc3jp(~5?IAurNV zH~o;zljv-!qyCEHKV;vV20Afo;G6)eIjGdgS@3EjH%|tSpbJB}#vZk0N5t1gc`_nA z#kI4{CvoX8Zj;IDK2Iao%KZ)e4Siwdrr?n_u2Tyh#st`)tJ+gix468dvSzXGmCYyj z$8k&>9YwuH?#ESv*VY-WQ|)1``E00Bn11gCW36_pJu=i96KadL*lbfGBc|BxlVc;J z^YP^_Zj=rlL+mB)DX8s7ZI?JVVlHzdp`L-qU*^`T;>j z7pPIVYb(DOBEuOVp(oENx@eD@WDU1k!Sl#)5DdoezQ|7nsO20{8b!xT(N^+xBP*}Eq@SJjsS(kb%iaEYd|}?gt7QUmvEK=C(yQ!gpVdo zWNfy9Zr)?n+UCsWn%Inm*-cqF(1Ex8MJN(M9x;c|VdBq7^Q91eFKgtV;Ft1o{Gj&u zApF-$!VC_)jljRiWv)4ub*S@8!Q}6AijRSiku&;1n1*kDDKNhy)r&d|t%z?EH$n1$ zdz+XGa_|pr;%JcS4a?HlrtGxzyn0VbW{vrT7gDq!vnV+xGd8oXcB%QO7gC%Zo06HH zozqz7Of(1oL5B`mDHVw%4n-5;4TzGW2+P7utSF+>YYIWq-Wr zvUnT>2G9fPgsw;J&4ecJb1`4IA58;lk0vx1{~?ZI7;!bVhB%vej$xk@`B4nO)jx_aj00a`Z8!WAU|E`i5T6_=MLg>!K=rEe#Vt7XK|H|fO zDCed$8fxF5Yqz8c=*Ue;Lt#4<5&!9?2{!p33@As}zZLxO z_$c`yp!p^)Xvf5Obpm)Cv2k)dl)gmCaq(^QnPEW2|4e8%gW6${H7q7t%lGTif$%}4ML zRkCIREr@NA+Zj1VwxEqIay1rPDKCSp6~ zhdKR-zDCa?X3xgwI^_(Fv}>dn2|=C5+%(`w+aquK=bil zw|ea-`Jc2m{zA|rx8>pJ*ljs@aQ`Rz9K-yoHoiooth35s{OSA3Q~)Iob)8boX!0@TJfocg4aj*}GUMRm$`?t?Yk@@RZ|D!h z4}qkIXkTd?v{r2)T&;y^ton(%TV1a%Q}fm7szs%hi?9g%TfuOe^1R|!7Awh0sN%0c z`6`n}e?#w*56Le=i~K9u$&8n$%7dhTNf+og=?!V4)F9!gTP85DY#C^92?>33u^d^QZV-{Ik4^&*1HR zAa{$q!W{>Pxt%QF)^W|8lQ~4s<5IZEoSD7BUSf~X4eZP8YPNz+WQWt^iH$7?*!WT2 z-rspwZ5-T9iIA+)Hvs)}@Bp(Sp2 zwHvz8UqnyJ2QSzZN(Q~Ex=QBT$bh?x#(o3(OnjCxUhT`gAql~YQKlCBJq|01uI zQ)MW1N==eo{7F0{J}>5rqlI%|xX>o#2qOP4{tADVKg93lckt`^mAr>9=5zR|{77Ep zy18@QJKRg$8m^AZ=i<5XoX&p7e$2kZE`E_Hvx=R|Mzh0MmidPHggM6SV>UA@m`0|8 z$zu|jQH(<0pg*Ti(EI4;=||}$^a6SoJ%KjB8}JG|1^)=Q!_}|`J_J)?1RMq>a21>e zZ-dRC859!(O-G7~7MoA-|xv$DR*RYzB%cZ*dw zr2hj|JJmtRR-z(Vq61)IwbNZpSD?!!Y9PK;qE2H_%O_+s?DMj~`}q!9-VTHM=oCD6 ziTViiY9I-ns#LA0<**!t3#!!F0L2%nA*gmK(GB@6p{_jD0%10qaa6twF`&?oNLhd8 zkQmfQrLl0%XgVF8u2e0k%ccH+b~)Ab{WU7cg6wY*-KmB`*$>-0)uk*W1ImTB9af9j zVJ11j7BJ*L!E}KVNW${0bjvBWfZ^di02Id=&FJ03YS@^5o^BqnnU9IUPkf*@Mhud= zsG;gFJ zXE>yEYBQj^4LLeBCwjG0`y8)1puGnm{|wH4Pn*FoDo+{ky7x5?%c#ex-=IrdU@)3+ zQfmwiq{Hvb@BL(kZk--+2T|-4> zp%#L&PHSUv$Z5?iLvVH7wuj9)?}By}cx5X%tqn%T8DubNyr`|o2K!06(4RujTJ^PP zq)lId8*F+a50>FKWAwLxzw|0KI@b|P8j7mo#(9;6#$W<5;isqQ%UH0Gv@?1F)LKw# zoL+<4*BbnBSDZdm1|F16%Y*TR41GQ%Gu@C-SY6{C9P92K(#SgDg-pG)HE2A4iPBH# z&*{awRr|MgR9mkVXd(P1!Hk9)*g$OW(tppR)bqL@3gnEc`IlwTd^?7|m8Eq1NM0}iK1S6IUKkhMFPYTN`S30EmHXEP>ftucXhVQVl_?O4t z590gSz|I>Rm0!6st>1CsWaRaI@XmR@a`c|(1L>J|21^6S2N`zHr5TKAT2)8ZBQ{}{(^~ichjtTks1!sYaMbRK5JH`o~2t5 z>KvjD!v7qmN)UrfhRI_g=`L-Y@xux9NuD%QR^z)2f06$PAF&!`-Xzd|-c1>_|NO;< zzl=5x3pA`Jy^;PKI$VETSgY5w2azeuXou`hl$d4w3R|;{D`-gCsL$Th#-R&2#;%aN zsd-t2tGId@*>id525+m|n@4{R%CYnuh<+bo8i9YzH40OBWh|LnNUxK+m5)M=ZALev zU)0}1-JN7hDYP4hlCfgr0U%-WJ(%o?D0(BFxY@Xwj_+L>g#WxWh>e^?wh2~f_io*M zHQh%M6WvQ;lSpKiz>Q>9GQQ7{qO_4X)*;|MWy=4Z>78S3$kJ{cfq&X=93HIsE;C~6 zc>Dq51s6SvdXlK$lt|y8o+Pzy+SIC5o}R2a=I1m^Jj=&v>U`#;dp!yr_?_tgP_Ihw zLqwlWDQw^xb{~(9&Bxnqra~=97V;@QSKFoW zYN660UzH=JcCk|wg?ys%X+l`*q?LBnHcs$UBLylcY5JXT_8rW8J2aGiP7Yo^u!#n= z_#L0{#q}+jVk7hK2X75uX;W>{_g<^lHbK1$rh)pwp8FNvbHA8g2k|}YUXS#}_1!Nf zYT*0DkbKdZW{Mx{on*#Pi^wcunrNNQe#q7_4>LpPH<>TsS9Cu29QPw!2K~VnV5Q!r z77^mU&!Udms5M*iclatg-PKTC)0`Ey%ad~rCtRo`Jcj5XVtnK-g9S#IuQHIRx&-;$wdTP>Q!}Akz6Cx9Q7Pk+; zIVLvW(}8yXO$+KNM%ljzfV$*ncV>9f600r6w;6vB*3tQkI#QACnjDbkvyp75b{1zQ zE_F|;@LgR!7(b%l-I_l1Zf7(tyD%dxd{Ti@E0D_>!5g1M}kZZ2^}GfF^&aizU ztr~{M`flhR;Q1JPzOzG-seou?kxYY7L5_(-uQ?1HZd+#Z2+S-YkwT|x#6VbiSA8bEXzvQ%NY&S_h>+ zp$BOl)qF9x8tDIz=1 zH|G0x+K#2wvB`y{$r-7>Z;Bp-dz3ehMkso39Od`Mk?$GB*^!eb*(Wtu75TokdN9^r zJwfejN-xm9CR)0rO|M~eU z=2T|!8nj5ajiP3mQ|Qa54L-4K9>ji)`qJOI`d;<>ectDH&iP}Xz1LcMO?$2H_WRTQ z9cj(s)>jOJ1~Y#=!&`>LqOV>k+F^*3^h|pmMYpQvddx8G+svmlzOK~QSTLbIGvY-H z9emM+nujZ&F$P-Bh$*5|7$AJg-N|>8yEqOnCob)*woOe(KceNzPbh{t$`mrmElJMs z@^B#}Ap|uUXusBOR%|U4wAM9;WPXr?4;dTMdPNCsZ+~-xVA=|ojtxP~O~LiG74Xb;G`&6j z_{;soU}JH>oc^t9_YgX&i%T}z7vES@V_5d=kLq)Js(!<88^>vI4O*jN?MrxK37Sas z@0nOQ@CoS+i91XI5??nZ=(!Y~vmF9IEJcav8SuVEg5cY2vJShymj!G|;9Z8QV8k*s zl>T!WYAb}@eu;y*2UVHad))(F&k13$`3XS^{4Rp#BSIMc`2sTi2vM;JcN%w^>;RNh zH+R&0_l&ZX;z=n{Gi}NgiyeUDavN$YYjZPG=fuTZ^RT{@5p=@ZcJ(n98NGC(?=eo= zCg2PmGpC_8Uk~Nc5<_H0g7-anDC|C<5FQV;>2)wisr_Kwpc71QfmS+;|>X^x?{Kqw+^ z7+pk8f#xM~knAM|!`nm7uDi-uOJ6M`lLT@U?O>=`L)IYSELXrC=C;#~ zwPX%U6k@Brx{O3XzxgEI3W!N6iJw+6+q0-_?(7sN%$-jLLH|{xzhh)dTvcpJ?rc|F z!H6E&0ZRJLd~%0O_M>(T4nN6<3O^Ywitlg^E>(OIUi6bornXhviVxsjcr#vuHN22M z(nL}P^a5PC-`>=>k&J zY8B&{=*yonHN9$lYF2Wia~FMJ6=|G^vViL(eIUA%+{Z5Ev*@#(t{IubZu8lR)U?D(QkwZVzsabcNESzj24g zp#79Hc`t%8i;u)3bLjmRKTSdF;GF*2A4$REoOhUn|MO1r<~oe0$f*Udi#ySw@Egij+p zT<(^`sB(qBhB41m@dy4ovKN>rxL9QM0QHO|itI#0BUujy>jNvC$8&7W`rTT-F~XEF zn?fsp;vZ@w>~f|6xNX872YKlKz7V<1ilAR@6ESQQ7qxOWD zchsXR{m%>3|Gc<73VH1W5x2t5PH`Y+KA^`t#rFtW0Nc-qX9$0sxgCamDaO!e|1RcB zXu9n{vCSdp$wPz-!b_YbtPtQzE*5AlfC_2`Q7)vvV5jy~)l4w%Z)u?1}EY ztdgpng(+_5lXUiaX;UU)qnRzX9@1vm*(E(rP!SsqNwG>Woq16@iionC35CU1r3l!x zNKt9-C(;Zt)D{+6SXNq5Q8vGl5#{q(e%SqMa8ZASMrYHy!iRC^V8}YJw6xe8HhDBf2#)5pmgQ3 z6CD7qNTKjXk#dr*_9_`_WWQ3^WZ%MB84cMLd3iSrcv*rkFJ|`C%KZEhNzRw(PnC-9 zKudx1D=o0muT-t86uapzMG~C8q1;3a zVK1q~WZ%Rr2&QTqWvcri$5NA^Wjji>%bO4h=eMJQly#`rdoN?hGPTSP4F1k;A2r4a zV}+5b|EllNi}lI+Ks-SEr*>L>Ns_X+C#aj ze4@Ooyh?)ESJ)*=i&BGLSEeY#*dG;9{#ZUNx3PE2E96K@txHGp{fLd_A) zzY*T=jlTS)kZ|b z40a!QgDfX(4zomhdV`h2!oX*(U>Nh5l>}K2X&SxswmJe~{p8Ks4#VD2&%y4aY8dTv zRQ+@U@5dt;LP6{_CxGUXqoAVG+(5-s<}MbJ&YDhmqfL?Md+(XDbkln1{Ltj+&*$yN zLs6v0n&_~7vbA(N)uoL`biG^KETRnR8m;x>9L9e!4qtU#+SJmdw5qs<8O~Sf%yHVz z$Ym9%KWn^&Q_wZE+gN4f8PRsKoz|aYHGD6Ai%rrCbxV6kyGNU-p45=KLQS+&YoAi9 z43_^}eobzXr^>ygGt#5dWNEB4ND7lA@mKLn@qO_f@nx}1d{p#{bHvGFoY+fbg|CED z!aiZEutKO5a)bmSLb#jdzu-^sFY>@I=8O3>K8BCvb?&O2E=SO4?qzN#*J>xsU$`_b zmg|M8Ifh&!r)==mpX?-SNdxhsjU*jSAZ`*y2zv$H#eTrP%|64fLo3)y`y?kX3u1@j z;>O6E-_Tjr74s{r>e&jJ4Z+3L-ntUD{C^QtdTWMyD}3zi9_*k$*nHmNic+>Hm>vGd z+$?WJX;q20j{VD@N$ZEssVgsGr~NJz&K7GLNh3``aOH^7_ZE2Ws@5W5=0{REbXIFo za6MKvA?sab4BUA{35UcgEgG83w40-z(3Pv17-z$SZ%RD_YoQ#Taywf97Y|Dzfqz+? zu%NWcSC2E{F|REx?S!5OPlstLY<>(!Lrt+Z5snvY23#rDvLSz#RtFbnX#j1TaVQi% zf;qaaL>oz9SqttNI7cSDyWb(x{Bmt6W{0!hKQ@BS0!`*{dOdyConJ zWZv>Rg>FBr&EnKmipL!iem5E`GU83(1*;Bd6$n{+ zER4z0W1uEWZ(#Rfpr2*w83;PQw8ns|5r@(hQ}v~l;z9Hvuk+eb5IgicA+&=smh{D%N(&H&-9?6fb&UbZ%nI&kuY zK8gfJR6eO+MPvyYVwWeEoC;Z;`b#kRlpalooYI{N#(i72-{YjYAL-wraI`s$jl+v( zxQa`1Q>$DH(&MJhs%ywED434HzOaPR#((G!WTSm{r7+fDc@;FfjCELUfC#sdPZzn3 zL;*cVUmazYPoY1qzBDyPeOaTZ%N#ZkF8MVNY~wAR`gv>lAoiH>xy@tkr$=3u5>O$Sem>Ib z9jdQKN070RjWzBQ*BSG{nPtU5YL@i{)MQ(sG&$Rykk0t}z zDH#4b!VyJ(ooS#f>d1m1c0z7ZqmZa=XKo zhv?&ON3k9(Nga$aUEi$>TCv)ue5b_8EmDUqa;HQ)COMJ@^7v~e0xz_iqhoN;ul!i< zZ(KbY#hzmu$!B<>T@n}C#IYE@sB_dAdLK~7L)0j(l~E&$+VRC;jww? zxea6KXFkV^2=GZ`Kyc~Qg53NGwV89P|9nY+mTa>ZTy{Hn-tBVFsAz+h0<{<1ubF++ zX;Xcv31ta$eG_6z0(5;9+fOsRZK|#QNQjgjK~R+A;9*yq$ { + beforeAll(async () => { + config.General.noAuthentication = false; + config.ExternalBlobStorage.enabled = true; + config.ExternalBlobStorage.thresholdBytes = 10; + + const buildApp = (await import("../../src/app.js")).default; + app = await buildApp(); + token = await login(app); + }); + + it("stores small note content internally", async () => { + const payload = "a".repeat(10); + const createRes = await supertest(app) + .post("/etapi/create-note") + .auth(USER, token, { "type": "basic"}) + .send({ + "parentNoteId": "root", + "title": "Internal Blob Test", + "mime": "text/plain", + "type": "text", + "content": payload + }) + .expect(201); + + const createdNoteId: string = createRes.body.note.noteId; + expect(createdNoteId).toBeTruthy(); + + const blobId = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", [createdNoteId]); + expect(blobId).toBeTruthy(); + + const row = sql.getRow<{ contentLocation: string; content: string | null; contentLength: number }>( + "SELECT contentLocation, content, contentLength FROM blobs WHERE blobId = ?", + [blobId] + ); + + expect(row).toBeTruthy(); + expect(row.contentLength).toEqual(payload.length); + expect(row.contentLocation).toEqual("internal"); + expect(row.content).toEqual(payload); + }); + + it("stores large note content externally and serves it back", async () => { + const payload = "a".repeat(11); + const createRes = await supertest(app) + .post("/etapi/create-note") + .auth(USER, token, { "type": "basic"}) + .send({ + "parentNoteId": "root", + "title": "External Blob Test", + "mime": "application/octet-stream", + "type": "file", + "content": payload + }) + .expect(201); + + const createdNoteId: string = createRes.body.note.noteId; + expect(createdNoteId).toBeTruthy(); + + const blobId = sql.getValue("SELECT blobId FROM notes WHERE noteId = ?", [createdNoteId]); + expect(blobId).toBeTruthy(); + + const row = sql.getRow<{ contentLocation: string; content: string | null; contentLength: number }>( + "SELECT contentLocation, content, contentLength FROM blobs WHERE blobId = ?", + [blobId] + ); + + expect(row).toBeTruthy(); + expect(row.contentLength).toEqual(payload.length); + expect(row.contentLocation.startsWith("file://")).toBe(true); + expect(row.content).toBeNull(); + + const getRes = await supertest(app) + .get(`/etapi/notes/${createdNoteId}/content`) + .auth(USER, token, { "type": "basic"}) + .expect(200); + + expect(getRes.body.toString()).toEqual(payload); + }); +}); + + diff --git a/apps/server/src/assets/db/schema.sql b/apps/server/src/assets/db/schema.sql index 07d924a915..409899e00f 100644 --- a/apps/server/src/assets/db/schema.sql +++ b/apps/server/src/assets/db/schema.sql @@ -107,6 +107,8 @@ CREATE TABLE IF NOT EXISTS "recent_notes" CREATE TABLE IF NOT EXISTS "blobs" ( `blobId` TEXT NOT NULL, `content` TEXT NULL DEFAULT NULL, + `contentLocation` TEXT NOT NULL DEFAULT 'internal', + `contentLength` INTEGER NOT NULL DEFAULT 0, `dateModified` TEXT NOT NULL, `utcDateModified` TEXT NOT NULL, PRIMARY KEY(`blobId`) diff --git a/apps/server/src/becca/becca-interface.ts b/apps/server/src/becca/becca-interface.ts index 005a5cc520..b2a7c760bc 100644 --- a/apps/server/src/becca/becca-interface.ts +++ b/apps/server/src/becca/becca-interface.ts @@ -171,7 +171,7 @@ export default class Becca { opts.includeContentLength = !!opts.includeContentLength; const query = opts.includeContentLength - ? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength + ? /*sql*/`SELECT attachments.*, blobs.contentLength AS contentLength FROM attachments JOIN blobs USING (blobId) WHERE attachmentId = ? AND isDeleted = 0` @@ -197,7 +197,7 @@ export default class Becca { return null; } - const row = sql.getRow("SELECT *, LENGTH(content) AS contentLength FROM blobs WHERE blobId = ?", [entity.blobId]); + const row = sql.getRow("SELECT * FROM blobs WHERE blobId = ?", [entity.blobId]); return row ? new BBlob(row) : null; } diff --git a/apps/server/src/becca/entities/abstract_becca_entity.ts b/apps/server/src/becca/entities/abstract_becca_entity.ts index 1f3bd0d863..7b47fc0066 100644 --- a/apps/server/src/becca/entities/abstract_becca_entity.ts +++ b/apps/server/src/becca/entities/abstract_becca_entity.ts @@ -9,8 +9,11 @@ import cls from "../../services/cls.js"; import log from "../../services/log.js"; import protectedSessionService from "../../services/protected_session.js"; import blobService from "../../services/blob.js"; +import blobStorageService from "../../services/blob-storage.js"; +import type { Blob } from "../../services/blob-interface.js"; import type { default as Becca, ConstructorData } from "../becca-interface.js"; import becca from "../becca.js"; +import type { BlobContentLocation, BlobRow } from "@triliumnext/commons"; interface ContentOpts { forceSave?: boolean; @@ -195,6 +198,11 @@ abstract class AbstractBeccaEntity> { return; } + if (blobStorageService.hasExternalContentColumns()) { + const row = sql.getRow<{ contentLocation: string }>("SELECT contentLocation FROM blobs WHERE blobId = ?", [oldBlobId]); + blobStorageService.deleteExternal(row); + } + sql.execute("DELETE FROM blobs WHERE blobId = ?", [oldBlobId]); // blobs are not marked as erased in entity_changes, they are just purged completely // this is because technically every keystroke can create a new blob, and there would be just too many @@ -225,14 +233,39 @@ abstract class AbstractBeccaEntity> { return newBlobId; } - const pojo = { + // Check if we should store this blob externally + const shouldStoreExternally = blobStorageService.shouldStoreExternally(content); + let contentLocation: BlobContentLocation = "internal"; + if (shouldStoreExternally) { + try { + contentLocation = blobStorageService.saveExternal(newBlobId, content); + } catch (error) { + log.error(`Failed to store blob ${newBlobId} externally, falling back to internal storage: ${error}`); + contentLocation = "internal"; + } + } + + const contentLength = blobService.getContentLength(content); + + const pojo: BlobRow = { blobId: newBlobId, - content: content, + content: contentLocation === 'internal' ? content : null, + contentLocation, + contentLength, dateModified: dateUtils.localNowDateTime(), utcDateModified: dateUtils.utcNowDateTime() }; - sql.upsert("blobs", "blobId", pojo); + // external content columns might not be present when applying older migrations + const pojoToSave = blobStorageService.hasExternalContentColumns() + ? pojo + : { + blobId: pojo.blobId, + content, + dateModified: pojo.dateModified, + utcDateModified: pojo.utcDateModified + }; + sql.upsert("blobs", "blobId", pojoToSave); // we can't reuse blobId as an entity_changes hash, because this one has to be calculatable without having // access to the decrypted content @@ -259,14 +292,20 @@ abstract class AbstractBeccaEntity> { } protected _getContent(): string | Buffer { - const row = sql.getRow<{ content: string | Buffer }>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); + const query = blobStorageService.hasExternalContentColumns() + ? /*sql*/`SELECT content, contentLocation FROM blobs WHERE blobId = ?` + : /*sql*/`SELECT content, 'internal' as contentLocation FROM blobs WHERE blobId = ?`; + + const row = sql.getRow<{ content: string | Buffer, contentLocation: string }>(query, [this.blobId]); if (!row) { const constructorData = this.constructor as unknown as ConstructorData; throw new Error(`Cannot find content for ${constructorData.primaryKeyName} '${(this as any)[constructorData.primaryKeyName]}', blobId '${this.blobId}'`); } - return blobService.processContent(row.content, this.isProtected || false, this.hasStringContent()); + const content = blobStorageService.getContent(row); + + return blobService.processContent(content, this.isProtected || false, this.hasStringContent()); } /** diff --git a/apps/server/src/becca/entities/bblob.ts b/apps/server/src/becca/entities/bblob.ts index 2cff185d5c..d21e2ae883 100644 --- a/apps/server/src/becca/entities/bblob.ts +++ b/apps/server/src/becca/entities/bblob.ts @@ -1,5 +1,5 @@ import AbstractBeccaEntity from "./abstract_becca_entity.js"; -import type { BlobRow } from "@triliumnext/commons"; +import type { BlobRow, BlobContentLocation } from "@triliumnext/commons"; // TODO: Why this does not extend the abstract becca? class BBlob extends AbstractBeccaEntity { @@ -10,11 +10,12 @@ class BBlob extends AbstractBeccaEntity { return "blobId"; } static get hashedProperties() { - return ["blobId", "content"]; + return ["blobId", "content", "contentLocation"]; } - content!: string | Buffer; + content!: string | Buffer | null; contentLength!: number; + contentLocation!: BlobContentLocation; constructor(row: BlobRow) { super(); @@ -25,6 +26,7 @@ class BBlob extends AbstractBeccaEntity { this.blobId = row.blobId; this.content = row.content; this.contentLength = row.contentLength; + this.contentLocation = row.contentLocation; this.dateModified = row.dateModified; this.utcDateModified = row.utcDateModified; } @@ -34,6 +36,7 @@ class BBlob extends AbstractBeccaEntity { blobId: this.blobId, content: this.content || null, contentLength: this.contentLength, + contentLocation: this.contentLocation, dateModified: this.dateModified, utcDateModified: this.utcDateModified }; diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts index dd05fd9746..f77ff93e26 100644 --- a/apps/server/src/becca/entities/bnote.ts +++ b/apps/server/src/becca/entities/bnote.ts @@ -10,6 +10,7 @@ import dateUtils from "../../services/date_utils.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import BRevision from "./brevision.js"; import BAttachment from "./battachment.js"; +import blobStorageService from "../../services/blob-storage.js"; import TaskContext from "../../services/task_context.js"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc.js"; @@ -1107,8 +1108,12 @@ class BNote extends AbstractBeccaEntity { // from testing, it looks like calculating length does not make a difference in performance even on large-ish DB // given that we're always fetching attachments only for a specific note, we might just do it always + const contentLengthColumn = blobStorageService.hasExternalContentColumns() + ? "blobs.contentLength" + : "LENGTH(COALESCE(blobs.content, ''))"; + const query = opts.includeContentLength - ? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength + ? /*sql*/`SELECT attachments.*, ${contentLengthColumn} AS contentLength FROM attachments JOIN blobs USING (blobId) WHERE ownerId = ? AND isDeleted = 0 @@ -1121,8 +1126,12 @@ class BNote extends AbstractBeccaEntity { getAttachmentById(attachmentId: string, opts: AttachmentOpts = {}) { opts.includeContentLength = !!opts.includeContentLength; + const contentLengthColumn = blobStorageService.hasExternalContentColumns() + ? "blobs.contentLength" + : "LENGTH(COALESCE(blobs.content, ''))"; + const query = opts.includeContentLength - ? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength + ? /*sql*/`SELECT attachments.*, ${contentLengthColumn} AS contentLength FROM attachments JOIN blobs USING (blobId) WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0` diff --git a/apps/server/src/becca/entities/brevision.ts b/apps/server/src/becca/entities/brevision.ts index 88f647db29..4d06f00763 100644 --- a/apps/server/src/becca/entities/brevision.ts +++ b/apps/server/src/becca/entities/brevision.ts @@ -6,6 +6,7 @@ import dateUtils from "../../services/date_utils.js"; import becca from "../becca.js"; import AbstractBeccaEntity from "./abstract_becca_entity.js"; import sql from "../../services/sql.js"; +import blobStorageService from "../../services/blob-storage.js"; import BAttachment from "./battachment.js"; import type { AttachmentRow, NoteType, RevisionPojo, RevisionRow } from "@triliumnext/commons"; import eraseService from "../../services/erase.js"; @@ -140,8 +141,12 @@ class BRevision extends AbstractBeccaEntity { getAttachmentById(attachmentId: String, opts: GetByIdOpts = {}): BAttachment | null { opts.includeContentLength = !!opts.includeContentLength; + const contentLengthColumn = blobStorageService.hasExternalContentColumns() + ? "blobs.contentLength" + : "LENGTH(COALESCE(blobs.content, ''))"; + const query = opts.includeContentLength - ? /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength + ? /*sql*/`SELECT attachments.*, ${contentLengthColumn} AS contentLength FROM attachments JOIN blobs USING (blobId) WHERE ownerId = ? AND attachmentId = ? AND isDeleted = 0` diff --git a/apps/server/src/migrations/migrations.ts b/apps/server/src/migrations/migrations.ts index 2757b4c25a..808a5b2107 100644 --- a/apps/server/src/migrations/migrations.ts +++ b/apps/server/src/migrations/migrations.ts @@ -6,6 +6,19 @@ // Migrations should be kept in descending order, so the latest migration is first. const MIGRATIONS: (SqlMigration | JsMigration)[] = [ + // Add external blob storage support + { + version: 234, + sql: /*sql*/` + -- Add contentLocation column + ALTER TABLE blobs ADD contentLocation TEXT DEFAULT 'internal'; + UPDATE blobs SET contentLocation = 'internal' WHERE contentLocation IS NULL; + + -- Add contentLength column + ALTER TABLE blobs ADD contentLength INTEGER DEFAULT 0; + UPDATE blobs SET contentLength = CASE WHEN content IS NULL THEN 0 ELSE LENGTH(content) END WHERE contentLength IS NULL; + `, + }, // Migrate geo map to collection { version: 233, diff --git a/apps/server/src/routes/api/revisions.ts b/apps/server/src/routes/api/revisions.ts index d9b7b975ed..6f206f4311 100644 --- a/apps/server/src/routes/api/revisions.ts +++ b/apps/server/src/routes/api/revisions.ts @@ -7,6 +7,7 @@ import cls from "../../services/cls.js"; import path from "path"; import becca from "../../becca/becca.js"; import blobService from "../../services/blob.js"; +import blobStorageService from "../../services/blob-storage.js"; import eraseService from "../../services/erase.js"; import type { Request, Response } from "express"; import type BRevision from "../../becca/entities/brevision.js"; @@ -33,10 +34,14 @@ function getRevisionBlob(req: Request) { } function getRevisions(req: Request) { + const contentLengthColumn = blobStorageService.hasExternalContentColumns() + ? "blobs.contentLength" + : "LENGTH(COALESCE(blobs.content, ''))"; + return becca.getRevisionsFromQuery( ` SELECT revisions.*, - LENGTH(blobs.content) AS contentLength + ${contentLengthColumn} AS contentLength FROM revisions JOIN blobs ON revisions.blobId = blobs.blobId WHERE revisions.noteId = ? diff --git a/apps/server/src/routes/api/stats.ts b/apps/server/src/routes/api/stats.ts index aebca079e3..1d4b05a14e 100644 --- a/apps/server/src/routes/api/stats.ts +++ b/apps/server/src/routes/api/stats.ts @@ -1,14 +1,19 @@ import sql from "../../services/sql.js"; import becca from "../../becca/becca.js"; +import blobStorageService from "../../services/blob-storage.js"; import type { Request } from "express"; import { NoteSizeResponse, SubtreeSizeResponse } from "@triliumnext/commons"; function getNoteSize(req: Request) { const { noteId } = req.params; + const contentLengthColumn = blobStorageService.hasExternalContentColumns() + ? "blobs.contentLength" + : "LENGTH(COALESCE(blobs.content, ''))"; + const blobSizes = sql.getMap( ` - SELECT blobs.blobId, LENGTH(content) + SELECT blobs.blobId, ${contentLengthColumn} as contentLength FROM blobs LEFT JOIN notes ON notes.blobId = blobs.blobId AND notes.noteId = ? AND notes.isDeleted = 0 LEFT JOIN attachments ON attachments.blobId = blobs.blobId AND attachments.ownerId = ? AND attachments.isDeleted = 0 @@ -33,8 +38,12 @@ function getSubtreeSize(req: Request) { sql.fillParamList(subTreeNoteIds); + const contentLengthColumn = blobStorageService.hasExternalContentColumns() + ? "blobs.contentLength" + : "LENGTH(COALESCE(blobs.content, ''))"; + const blobSizes = sql.getMap(` - SELECT blobs.blobId, LENGTH(content) + SELECT blobs.blobId, ${contentLengthColumn} as contentLength FROM param_list JOIN notes ON notes.noteId = param_list.paramId AND notes.isDeleted = 0 LEFT JOIN attachments ON attachments.ownerId = param_list.paramId AND attachments.isDeleted = 0 diff --git a/apps/server/src/services/app_info.ts b/apps/server/src/services/app_info.ts index 2837e8de79..002f9c43b4 100644 --- a/apps/server/src/services/app_info.ts +++ b/apps/server/src/services/app_info.ts @@ -4,7 +4,7 @@ import packageJson from "../../package.json" with { type: "json" }; import dataDir from "./data_dir.js"; import { AppInfo } from "@triliumnext/commons"; -const APP_DB_VERSION = 233; +const APP_DB_VERSION = 234; const SYNC_VERSION = 36; const CLIPPER_PROTOCOL_VERSION = "1.0"; diff --git a/apps/server/src/services/blob-interface.ts b/apps/server/src/services/blob-interface.ts index a0e6052785..8fcdff1906 100644 --- a/apps/server/src/services/blob-interface.ts +++ b/apps/server/src/services/blob-interface.ts @@ -1,5 +1,7 @@ export interface Blob { blobId: string; - content: string | Buffer; + content: string | Buffer | null; + contentLocation: string; + contentLength: number; utcDateModified: string; } diff --git a/apps/server/src/services/blob-storage.ts b/apps/server/src/services/blob-storage.ts new file mode 100644 index 0000000000..00b3fdc39b --- /dev/null +++ b/apps/server/src/services/blob-storage.ts @@ -0,0 +1,128 @@ +import fs from "fs"; +import path from "path"; +import { randomUUID } from "crypto"; +import dataDirs from "./data_dir.js"; +import log from "./log.js"; +import config from "./config.js"; +import blob from "./blob.js"; +import sql from "./sql.js"; +import type { Blob } from "./blob-interface.js"; +import { BlobContentLocation } from "@triliumnext/commons"; + +const EXTERNAL_BLOB_DIR = "external-blobs"; + +export class BlobStorageService { + private externalBlobPath: string; + private _hasExternalContentColumns: boolean | null = null; + + constructor() { + this.externalBlobPath = path.join(dataDirs.TRILIUM_DATA_DIR, EXTERNAL_BLOB_DIR); + this.ensureExternalBlobDir(); + } + + /** + * Check if the external content columns (contentLocation, contentLength) exist in the blobs table. + * This is cached for performance. + * Returns false before migration 234 has been applied (for example when applying older migrations). + */ + hasExternalContentColumns(): boolean { + if (!this._hasExternalContentColumns) { + // In most cases the columns will already exist, so we intentionally recheck + // to see if the migration was applied after the first check. + this._hasExternalContentColumns = !!sql.getValue(/*sql*/` + SELECT 1 FROM pragma_table_info('blobs') WHERE name = 'contentLocation' + `); + } + + return this._hasExternalContentColumns; + } + + private ensureExternalBlobDir(): void { + if (!fs.existsSync(this.externalBlobPath)) { + try { + fs.mkdirSync(this.externalBlobPath, { recursive: true, mode: 0o700 }); + log.info(`Created external blob directory: ${this.externalBlobPath}`); + } catch (error) { + log.error(`Failed to create external blob directory: ${error}`); + throw error; + } + } + } + + /** + * Store blob content externally and return the relative file path + */ + saveExternal(blobId: string, content: string | Buffer): BlobContentLocation { + const uuid = randomUUID(); + const partition = uuid.substring(0, 2); + const relativePath = path.join(partition, `${uuid}.blob`); + const absolutePath = path.join(this.externalBlobPath, relativePath); + + try { + const dir = path.dirname(absolutePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + fs.writeFileSync(absolutePath, content, { mode: 0o600 }); + log.info(`Stored blob ${blobId} externally at: ${absolutePath}`); + return `file://${relativePath}`; + } catch (error) { + log.error(`Failed to store blob ${blobId} externally: ${error}`); + throw error; + } + } + + /** + * Retrieve blob content from external storage + */ + getExternal(relativePath: string): Buffer { + const filePath = path.join(this.externalBlobPath, relativePath); + try { + return fs.readFileSync(filePath); + } catch (error) { + log.error(`Failed to retrieve blob from external storage ${filePath}: ${error}`); + throw error; + } + } + + /** + * Get the content of a blob row + */ + getContent(row: Pick): string | Buffer | null { + return row.contentLocation === "internal" ? row.content : this.getExternal(row.contentLocation.replace("file://", "")); + } + + /** + * Delete external blob file asynchronously for cleanup + */ + deleteExternal(row: Pick): void { + if (!row.contentLocation || row.contentLocation === "internal") { + return; + } + + const relativePath = row.contentLocation.replace("file://", ""); + const filePath = path.join(this.externalBlobPath, relativePath); + + try { + fs.unlinkSync(filePath); + log.info(`Deleted external blob file: ${filePath}`); + } catch (error) { + log.error(`Failed to delete external blob file ${filePath}: ${error}`); + throw error; + } + } + + /** + * Check if a blob should be stored externally based on size + */ + shouldStoreExternally(content: string | Buffer): boolean { + if (!config.ExternalBlobStorage.enabled) { + return false; + } + + return blob.getContentLength(content) > config.ExternalBlobStorage.thresholdBytes; + } +} + +export default new BlobStorageService(); diff --git a/apps/server/src/services/blob.ts b/apps/server/src/services/blob.ts index c4684c2ae5..1289c3187e 100644 --- a/apps/server/src/services/blob.ts +++ b/apps/server/src/services/blob.ts @@ -2,6 +2,7 @@ import becca from "../becca/becca.js"; import NotFoundError from "../errors/not_found_error.js"; import protectedSessionService from "./protected_session.js"; import { hash } from "./utils.js"; +import blobStorageService from "./blob-storage.js"; import type { Blob } from "./blob-interface.js"; function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boolean }) { @@ -21,7 +22,8 @@ function getBlobPojo(entityName: string, entityId: string, opts?: { preview: boo if (!entity.hasStringContent()) { pojo.content = null; } else { - pojo.content = processContent(pojo.content, !!entity.isProtected, true); + const content = blobStorageService.getContent(pojo); + pojo.content = processContent(content, !!entity.isProtected, true); } return pojo; @@ -50,12 +52,27 @@ function processContent(content: Buffer | string | null, isProtected: boolean, i } } -function calculateContentHash({ blobId, content }: Blob) { - return hash(`${blobId}|${content.toString()}`); +function calculateContentHash({ blobId, content, contentLocation }: Blob) { + const rawContent = blobStorageService.getContent({ content, contentLocation }); + const contentString = Buffer.isBuffer(rawContent) ? rawContent.toString('base64') : rawContent; + return hash(`${blobId}|${contentLocation}|${contentString}`); +} + +function getContentLength(content: Buffer | string | null) { + if (content === null) { + return 0; + } + + if (Buffer.isBuffer(content)) { + return content.length; + } + + return Buffer.byteLength(content, "utf8"); } export default { getBlobPojo, processContent, - calculateContentHash + calculateContentHash, + getContentLength }; diff --git a/apps/server/src/services/config.ts b/apps/server/src/services/config.ts index e08b7264e6..4450237145 100644 --- a/apps/server/src/services/config.ts +++ b/apps/server/src/services/config.ts @@ -134,7 +134,11 @@ export interface TriliumConfig { * log files created by Trilium older than the specified amount of time will be deleted. */ retentionDays: number; - } + }, + ExternalBlobStorage: { + enabled: boolean; + thresholdBytes: number; + }; } /** @@ -143,32 +147,38 @@ export interface TriliumConfig { */ export const LOGGING_DEFAULT_RETENTION_DAYS = 90; +/** + * Default threshold for external blob storage in bytes. + * 100kB by default, see performance impact here: https://www.sqlite.org/intern-v-extern-blob.html + */ +export const EXTERNAL_BLOB_STORAGE_DEFAULT_THRESHOLD_BYTES = 100 * 1024; + /** * Configuration value source with precedence handling. * This interface defines how each configuration value is resolved from multiple sources. */ interface ConfigValue { - /** + /** * Standard environment variable name following TRILIUM_[SECTION]_[KEY] pattern. * This is the primary way to override configuration via environment. */ standardEnvVar?: string; - /** + /** * Alternative environment variable names for additional flexibility. * These provide shorter or more intuitive names for common settings. */ aliasEnvVars?: string[]; - /** + /** * Function to retrieve the value from the parsed INI configuration. * Returns undefined if the value is not set in config.ini. */ iniGetter: () => IniConfigValue | IniConfigSection; - /** + /** * Default value used when no environment variable or INI value is found. * This ensures every configuration has a sensible default. */ defaultValue: T; - /** + /** * Optional transformer function to convert string values to the correct type. * Common transformers handle boolean and integer conversions. */ @@ -177,18 +187,18 @@ interface ConfigValue { /** * Core configuration resolution function. - * + * * Resolves configuration values using a clear precedence order: * 1. Standard environment variable (highest priority) - Follows TRILIUM_[SECTION]_[KEY] pattern * 2. Alias environment variables - Alternative names for convenience and compatibility * 3. INI config file value - User-defined settings in config.ini * 4. Default value (lowest priority) - Fallback to ensure valid configuration - * + * * This precedence allows for flexible configuration management: * - Environment variables for container/cloud deployments * - config.ini for traditional installations * - Defaults ensure the application always has valid settings - * + * * @param config - Configuration value definition with sources and transformers * @returns The resolved configuration value with appropriate type */ @@ -223,7 +233,7 @@ function getConfigValue(config: ConfigValue): T { * Helper function to safely access INI config sections. * The ini parser can return either a section object or a primitive value, * so we need to check the type before accessing nested properties. - * + * * @param sectionName - The name of the INI section to access * @returns The section object or undefined if not found or not an object */ @@ -237,15 +247,15 @@ function getIniSection(sectionName: string): IniConfigSection | undefined { /** * Transform a value to boolean, handling various input formats. - * + * * This function provides flexible boolean parsing to handle different * configuration sources (environment variables, INI files, etc.): * - String "true"/"false" (case-insensitive) - * - String "1"/"0" + * - String "1"/"0" * - Numeric 1/0 * - Actual boolean values * - Any other value defaults to false - * + * * @param value - The value to transform (string, number, boolean, etc.) * @returns The boolean value or false as default */ @@ -253,28 +263,28 @@ function transformBoolean(value: unknown): boolean { // First try the standard envToBoolean function which handles "true"/"false" strings const result = envToBoolean(String(value)); if (result !== undefined) return result; - + // Handle numeric boolean values (both string and number types) if (value === "1" || value === 1) return true; if (value === "0" || value === 0) return false; - + // Default to false for any other value return false; } /** * Complete configuration mapping that defines how each setting is resolved. - * + * * This mapping structure: * 1. Mirrors the INI file sections for consistency * 2. Defines multiple sources for each configuration value * 3. Provides type transformers where needed * 4. Maintains compatibility with various environment variable formats - * + * * Environment Variable Patterns: * - Standard: TRILIUM_[SECTION]_[KEY] (e.g., TRILIUM_GENERAL_INSTANCENAME) * - Aliases: Shorter alternatives (e.g., TRILIUM_OAUTH_BASE_URL) - * + * * Both patterns are equally valid and can be used based on preference. * The standard pattern provides consistency, while aliases offer convenience. */ @@ -450,18 +460,32 @@ const configMapping = { defaultValue: LOGGING_DEFAULT_RETENTION_DAYS, transformer: (value: unknown) => stringToInt(String(value)) ?? LOGGING_DEFAULT_RETENTION_DAYS } + }, + ExternalBlobStorage: { + enabled: { + standardEnvVar: 'TRILIUM_EXTERNAL_BLOB_STORAGE_ENABLED', + iniGetter: () => getIniSection("ExternalBlobStorage")?.enabled, + defaultValue: false, + transformer: transformBoolean + }, + thresholdBytes: { + standardEnvVar: 'TRILIUM_EXTERNAL_BLOB_STORAGE_THRESHOLD', + iniGetter: () => getIniSection("ExternalBlobStorage")?.thresholdBytes, + defaultValue: EXTERNAL_BLOB_STORAGE_DEFAULT_THRESHOLD_BYTES, + transformer: (value: unknown) => stringToInt(String(value)) ?? EXTERNAL_BLOB_STORAGE_DEFAULT_THRESHOLD_BYTES + } } }; /** * Build the final configuration object by resolving all values through the mapping. - * + * * This creates the runtime configuration used throughout the application by: * 1. Iterating through each section and key in the mapping * 2. Calling getConfigValue() to resolve each setting with proper precedence * 3. Applying type transformers where needed (booleans, integers) * 4. Returning a fully typed TriliumConfig object - * + * * The resulting config object is immutable at runtime and represents * the complete application configuration. */ @@ -502,6 +526,10 @@ const config: TriliumConfig = { }, Logging: { retentionDays: getConfigValue(configMapping.Logging.retentionDays) + }, + ExternalBlobStorage: { + enabled: getConfigValue(configMapping.ExternalBlobStorage.enabled), + thresholdBytes: getConfigValue(configMapping.ExternalBlobStorage.thresholdBytes) } }; @@ -509,26 +537,26 @@ const config: TriliumConfig = { * ===================================================================== * ENVIRONMENT VARIABLE REFERENCE * ===================================================================== - * + * * Trilium supports flexible environment variable configuration with multiple * naming patterns. Both formats below are equally valid and can be used * based on your preference. - * + * * CONFIGURATION PRECEDENCE: * 1. Environment variables (highest priority) * 2. config.ini file values * 3. Default values (lowest priority) - * + * * FULL FORMAT VARIABLES (following TRILIUM_[SECTION]_[KEY] pattern): * ==================================================================== - * + * * General Section: * - TRILIUM_GENERAL_INSTANCENAME : Custom instance identifier * - TRILIUM_GENERAL_NOAUTHENTICATION : Disable auth (true/false) * - TRILIUM_GENERAL_NOBACKUP : Disable backups (true/false) * - TRILIUM_GENERAL_NODESKTOPICON : No desktop icon (true/false) * - TRILIUM_GENERAL_READONLY : Read-only mode (true/false) - * + * * Network Section: * - TRILIUM_NETWORK_HOST : Bind address (e.g., "0.0.0.0") * - TRILIUM_NETWORK_PORT : Server port (e.g., "8080") @@ -539,15 +567,15 @@ const config: TriliumConfig = { * - TRILIUM_NETWORK_CORSALLOWORIGIN : CORS allowed origins * - TRILIUM_NETWORK_CORSALLOWMETHODS : CORS allowed HTTP methods * - TRILIUM_NETWORK_CORSALLOWHEADERS : CORS allowed headers - * + * * Session Section: * - TRILIUM_SESSION_COOKIEMAXAGE : Cookie lifetime in seconds - * + * * Sync Section: * - TRILIUM_SYNC_SYNCSERVERHOST : Sync server URL * - TRILIUM_SYNC_SYNCSERVERTIMEOUT : Sync timeout in milliseconds * - TRILIUM_SYNC_SYNCPROXY : Proxy URL for sync - * + * * Multi-Factor Authentication Section: * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHBASEURL : OAuth base URL * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTID : OAuth client ID @@ -555,23 +583,23 @@ const config: TriliumConfig = { * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERBASEURL : OAuth issuer URL * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERNAME : OAuth provider name * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERICON : OAuth provider icon - * + * * Logging Section: * - TRILIUM_LOGGING_RETENTIONDAYS : Log retention period in days - * + * * SHORTER ALTERNATIVE VARIABLES (equally valid, for convenience): * ================================================================ - * + * * Network CORS (with underscores): * - TRILIUM_NETWORK_CORS_ALLOW_ORIGIN : Same as TRILIUM_NETWORK_CORSALLOWORIGIN * - TRILIUM_NETWORK_CORS_ALLOW_METHODS : Same as TRILIUM_NETWORK_CORSALLOWMETHODS * - TRILIUM_NETWORK_CORS_ALLOW_HEADERS : Same as TRILIUM_NETWORK_CORSALLOWHEADERS - * + * * Sync (with SERVER prefix): * - TRILIUM_SYNC_SERVER_HOST : Same as TRILIUM_SYNC_SYNCSERVERHOST * - TRILIUM_SYNC_SERVER_TIMEOUT : Same as TRILIUM_SYNC_SYNCSERVERTIMEOUT * - TRILIUM_SYNC_SERVER_PROXY : Same as TRILIUM_SYNC_SYNCPROXY - * + * * OAuth (simplified without section name): * - TRILIUM_OAUTH_BASE_URL : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHBASEURL * - TRILIUM_OAUTH_CLIENT_ID : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTID @@ -579,14 +607,14 @@ const config: TriliumConfig = { * - TRILIUM_OAUTH_ISSUER_BASE_URL : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERBASEURL * - TRILIUM_OAUTH_ISSUER_NAME : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERNAME * - TRILIUM_OAUTH_ISSUER_ICON : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERICON - * + * * Logging (with underscore): * - TRILIUM_LOGGING_RETENTION_DAYS : Same as TRILIUM_LOGGING_RETENTIONDAYS - * + * * BOOLEAN VALUES: * - Accept: "true", "false", "1", "0", 1, 0 * - Default to false for invalid values - * + * * EXAMPLES: * export TRILIUM_NETWORK_PORT="8080" # Using full format * export TRILIUM_OAUTH_CLIENT_ID="my-client-id" # Using shorter alternative @@ -597,23 +625,23 @@ const config: TriliumConfig = { /** * The exported configuration object used throughout the Trilium application. * This object is resolved once at startup and remains immutable during runtime. - * + * * To override any setting: * 1. Set an environment variable (see documentation above) * 2. Edit config.ini in your data directory * 3. Defaults will be used if neither is provided - * + * * @example * // Accessing configuration in other modules: * import config from './services/config.js'; - * + * * if (config.General.noAuthentication) { * // Skip authentication checks * } - * + * * const server = https.createServer({ * cert: fs.readFileSync(config.Network.certPath), * key: fs.readFileSync(config.Network.keyPath) * }); */ -export default config; \ No newline at end of file +export default config; diff --git a/apps/server/src/services/entity_changes.ts b/apps/server/src/services/entity_changes.ts index c0a97c7d6b..582b34251b 100644 --- a/apps/server/src/services/entity_changes.ts +++ b/apps/server/src/services/entity_changes.ts @@ -6,6 +6,7 @@ import { randomString } from "./utils.js"; import instanceId from "./instance_id.js"; import becca from "../becca/becca.js"; import blobService from "../services/blob.js"; +import blobStorageService from "./blob-storage.js"; import type { EntityChange } from "@triliumnext/commons"; import type { Blob } from "./blob-interface.js"; import eventService from "./events.js"; @@ -146,7 +147,10 @@ function fillEntityChanges(entityName: string, entityPrimaryKey: string, conditi }; if (entityName === "blobs") { - const blob = sql.getRow("SELECT blobId, content, utcDateModified FROM blobs WHERE blobId = ?", [entityId]); + const query = blobStorageService.hasExternalContentColumns() + ? "SELECT blobId, content, contentLocation, contentLength, utcDateModified FROM blobs WHERE blobId = ?" + : "SELECT blobId, content, 'internal' as contentLocation, LENGTH(COALESCE(content, '')) as contentLength, utcDateModified FROM blobs WHERE blobId = ?"; + const blob = sql.getRow(query, [entityId]); ec.hash = blobService.calculateContentHash(blob); ec.utcDateChanged = blob.utcDateModified; ec.isSynced = true; // blobs are always synced diff --git a/apps/server/src/services/erase.ts b/apps/server/src/services/erase.ts index 92b28e5735..4d207cca9d 100644 --- a/apps/server/src/services/erase.ts +++ b/apps/server/src/services/erase.ts @@ -5,6 +5,7 @@ import optionService from "./options.js"; import dateUtils from "./date_utils.js"; import sqlInit from "./sql_init.js"; import cls from "./cls.js"; +import blobStorageService from "./blob-storage.js"; import type { EntityChange } from "@triliumnext/commons"; function eraseNotes(noteIdsToErase: string[]) { @@ -92,26 +93,34 @@ function eraseRevisions(revisionIdsToErase: string[]) { } function eraseUnusedBlobs() { - const unusedBlobIds = sql.getColumn(` - SELECT blobs.blobId + const contentLocationColumn = blobStorageService.hasExternalContentColumns() + ? "blobs.contentLocation" + : "'internal' as contentLocation"; + + const unusedBlobRows = sql.getManyRows<{ blobId: string; contentLocation: string }>(/*sql*/` + SELECT blobs.blobId, ${contentLocationColumn} FROM blobs LEFT JOIN notes ON notes.blobId = blobs.blobId LEFT JOIN attachments ON attachments.blobId = blobs.blobId LEFT JOIN revisions ON revisions.blobId = blobs.blobId WHERE notes.noteId IS NULL AND attachments.attachmentId IS NULL - AND revisions.revisionId IS NULL`); + AND revisions.revisionId IS NULL`, []); - if (unusedBlobIds.length === 0) { + if (unusedBlobRows.length === 0) { return; } - sql.executeMany(/*sql*/`DELETE FROM blobs WHERE blobId IN (???)`, unusedBlobIds); + // Clean up external blob files before deleting from database + unusedBlobRows.forEach(row => blobStorageService.deleteExternal(row)); + + const blobIds = unusedBlobRows.map(row => row.blobId); + sql.executeMany(/*sql*/`DELETE FROM blobs WHERE blobId IN (???)`, blobIds); // blobs are not marked as erased in entity_changes, they are just purged completely // this is because technically every keystroke can create a new blob and there would be just too many - sql.executeMany(/*sql*/`DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId IN (???)`, unusedBlobIds); + sql.executeMany(/*sql*/`DELETE FROM entity_changes WHERE entityName = 'blobs' AND entityId IN (???)`, blobIds); - log.info(`Erased unused blobs: ${JSON.stringify(unusedBlobIds)}`); + log.info(`Erased unused blobs: ${JSON.stringify(blobIds)}`); } function eraseDeletedEntities(eraseEntitiesAfterTimeInSeconds: number | null = null) { diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts index c36dddd740..878711711f 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts @@ -11,14 +11,14 @@ import protectedSessionService from "../../protected_session.js"; import striptags from "striptags"; import { normalize } from "../../utils.js"; import sql from "../../sql.js"; -import { - normalizeSearchText, - calculateOptimizedEditDistance, - validateFuzzySearchTokens, +import { + normalizeSearchText, + validateFuzzySearchTokens, validateAndPreprocessContent, fuzzyMatchWord, - FUZZY_SEARCH_CONFIG + FUZZY_SEARCH_CONFIG } from "../utils/text_utils.js"; +import blobStorageService from "../../blob-storage.js"; const ALLOWED_OPERATORS = new Set(["=", "!=", "*=*", "*=", "=*", "%=", "~=", "~*"]); @@ -41,7 +41,7 @@ interface ConstructorOpts { flatText?: boolean; } -type SearchRow = Pick; +type SearchRow = Pick & { contentLocation: string }; class NoteContentFulltextExp extends Expression { private operator: string; @@ -86,7 +86,7 @@ class NoteContentFulltextExp extends Expression { // Search through notes with content for (const row of sql.iterateRows(` - SELECT noteId, type, mime, content, isProtected + SELECT noteId, type, mime, content, isProtected, contentLocation FROM notes JOIN blobs USING (blobId) WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap') AND isDeleted = 0 @@ -170,50 +170,52 @@ class NoteContentFulltextExp extends Expression { return false; } - findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) { + findInText({ noteId, isProtected, content, type, mime, contentLocation }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) { if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) { return; } + let rawContent: string | Buffer | null = blobStorageService.getContent({ content: content ?? null, contentLocation }); + if (isProtected) { - if (!protectedSessionService.isProtectedSessionAvailable() || !content || typeof content !== "string") { + if (!protectedSessionService.isProtectedSessionAvailable() || !rawContent || typeof rawContent !== "string") { return; } try { - content = protectedSessionService.decryptString(content) || undefined; + rawContent = protectedSessionService.decryptString(rawContent); } catch (e) { log.info(`Cannot decrypt content of note ${noteId}`); return; } } - if (!content) { + if (!rawContent) { return; } - content = this.preprocessContent(content, type, mime); + rawContent = this.preprocessContent(rawContent, type, mime); // Apply content size validation and preprocessing - const processedContent = validateAndPreprocessContent(content, noteId); + const processedContent = validateAndPreprocessContent(rawContent, noteId); if (!processedContent) { return; // Content too large or invalid } - content = processedContent; + rawContent = processedContent; if (this.tokens.length === 1) { const [token] = this.tokens; let matches = false; if (this.operator === "=") { - matches = this.containsExactWord(token, content); + matches = this.containsExactWord(token, rawContent); // Also check flatText if enabled (includes attributes) if (!matches && this.flatText) { const flatText = becca.notes[noteId].getFlatText(); matches = this.containsExactPhrase([token], flatText, true); } } else if (this.operator === "!=") { - matches = !this.containsExactWord(token, content); + matches = !this.containsExactWord(token, rawContent); // For negation, check flatText too if (matches && this.flatText) { const flatText = becca.notes[noteId].getFlatText(); @@ -223,12 +225,12 @@ class NoteContentFulltextExp extends Expression { if ( matches || - (this.operator === "*=" && content.endsWith(token)) || - (this.operator === "=*" && content.startsWith(token)) || - (this.operator === "*=*" && content.includes(token)) || - (this.operator === "%=" && getRegex(token).test(content)) || - (this.operator === "~=" && this.matchesWithFuzzy(content, noteId)) || - (this.operator === "~*" && this.fuzzyMatchToken(normalizeSearchText(token), normalizeSearchText(content))) + (this.operator === "*=" && rawContent.endsWith(token)) || + (this.operator === "=*" && rawContent.startsWith(token)) || + (this.operator === "*=*" && rawContent.includes(token)) || + (this.operator === "%=" && getRegex(token).test(rawContent)) || + (this.operator === "~=" && this.matchesWithFuzzy(rawContent, noteId)) || + (this.operator === "~*" && this.fuzzyMatchToken(normalizeSearchText(token), normalizeSearchText(rawContent))) ) { resultNoteSet.add(becca.notes[noteId]); } @@ -236,12 +238,12 @@ class NoteContentFulltextExp extends Expression { // Multi-token matching with fuzzy support and phrase proximity if (this.operator === "~=" || this.operator === "~*") { // Fuzzy phrase matching - if (this.matchesWithFuzzy(content, noteId)) { + if (this.matchesWithFuzzy(rawContent, noteId)) { resultNoteSet.add(becca.notes[noteId]); } } else if (this.operator === "=" || this.operator === "!=") { // Exact phrase matching for = and != - let matches = this.containsExactPhrase(this.tokens, content, false); + let matches = this.containsExactPhrase(this.tokens, rawContent, false); // Also check flatText if enabled (includes attributes) if (!matches && this.flatText) { @@ -257,7 +259,7 @@ class NoteContentFulltextExp extends Expression { // Other operators: check all tokens present (any order) const nonMatchingToken = this.tokens.find( (token) => - !this.tokenMatchesContent(token, content, noteId) + !this.tokenMatchesContent(token, rawContent, noteId) ); if (!nonMatchingToken) { @@ -266,7 +268,7 @@ class NoteContentFulltextExp extends Expression { } } - return content; + return rawContent; } preprocessContent(content: string | Buffer, type: string, mime: string) { @@ -307,16 +309,16 @@ class NoteContentFulltextExp extends Expression { private tokenMatchesContent(token: string, content: string, noteId: string): boolean { const normalizedToken = normalizeSearchText(token); const normalizedContent = normalizeSearchText(content); - + if (normalizedContent.includes(normalizedToken)) { return true; } - + // Check flat text for default fulltext search if (!this.flatText || !becca.notes[noteId].getFlatText().includes(token)) { return false; } - + return true; } @@ -327,15 +329,15 @@ class NoteContentFulltextExp extends Expression { try { const normalizedContent = normalizeSearchText(content); const flatText = this.flatText ? normalizeSearchText(becca.notes[noteId].getFlatText()) : ""; - + // For phrase matching, check if tokens appear within reasonable proximity if (this.tokens.length > 1) { return this.matchesPhrase(normalizedContent, flatText); } - + // Single token fuzzy matching const token = normalizeSearchText(this.tokens[0]); - return this.fuzzyMatchToken(token, normalizedContent) || + return this.fuzzyMatchToken(token, normalizedContent) || (this.flatText && this.fuzzyMatchToken(token, flatText)); } catch (error) { log.error(`Error in fuzzy matching for note ${noteId}: ${error}`); @@ -348,45 +350,45 @@ class NoteContentFulltextExp extends Expression { */ private matchesPhrase(content: string, flatText: string): boolean { const searchText = this.flatText ? `${content} ${flatText}` : content; - + // Apply content size limits for phrase matching const limitedText = validateAndPreprocessContent(searchText); if (!limitedText) { return false; } - + const words = limitedText.toLowerCase().split(/\s+/); - + // Only skip phrase matching for truly extreme word counts that could crash the system if (words.length > FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT) { console.error(`Phrase matching skipped due to extreme word count that could cause system instability: ${words.length} words`); return false; } - + // Warn about large word counts but still attempt matching if (words.length > FUZZY_SEARCH_CONFIG.PERFORMANCE_WARNING_WORDS) { console.info(`Large word count for phrase matching: ${words.length} words - may take longer but will attempt full matching`); } - + // Find positions of each token const tokenPositions: number[][] = this.tokens.map(token => { const normalizedToken = normalizeSearchText(token); const positions: number[] = []; - + words.forEach((word, index) => { if (this.fuzzyMatchSingle(normalizedToken, word)) { positions.push(index); } }); - + return positions; }); - + // Check if we found all tokens if (tokenPositions.some(positions => positions.length === 0)) { return false; } - + // Check for phrase proximity using configurable distance return this.hasProximityMatch(tokenPositions, FUZZY_SEARCH_CONFIG.MAX_PHRASE_PROXIMITY); } @@ -400,18 +402,18 @@ class NoteContentFulltextExp extends Expression { const [pos1, pos2] = tokenPositions; return pos1.some(p1 => pos2.some(p2 => Math.abs(p1 - p2) <= maxDistance)); } - + // For more tokens, check if we can find a sequence where all tokens are within range const findSequence = (remaining: number[][], currentPos: number): boolean => { if (remaining.length === 0) return true; - + const [nextPositions, ...rest] = remaining; - return nextPositions.some(pos => - Math.abs(pos - currentPos) <= maxDistance && + return nextPositions.some(pos => + Math.abs(pos - currentPos) <= maxDistance && findSequence(rest, pos) ); }; - + const [firstPositions, ...rest] = tokenPositions; return firstPositions.some(startPos => findSequence(rest, startPos)); } @@ -424,12 +426,12 @@ class NoteContentFulltextExp extends Expression { // For short tokens, require exact match to avoid too many false positives return content.includes(token); } - + const words = content.split(/\s+/); - + // Only limit word processing for truly extreme cases to prevent system instability const limitedWords = words.slice(0, FUZZY_SEARCH_CONFIG.ABSOLUTE_MAX_WORD_COUNT); - + return limitedWords.some(word => this.fuzzyMatchSingle(token, word)); } diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 5ca4bda4a1..850af7fe91 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -11,6 +11,7 @@ import beccaService from "../../../becca/becca_service.js"; import { normalize, escapeHtml, escapeRegExp } from "../../utils.js"; import log from "../../log.js"; import hoistedNoteService from "../../hoisted_note.js"; +import blobStorageService from "../../blob-storage.js"; import type BNote from "../../../becca/entities/bnote.js"; import type BAttribute from "../../../becca/entities/battribute.js"; import type { SearchParams, TokenStructure } from "./types.js"; @@ -118,6 +119,10 @@ function loadNeededInfoFromDatabase() { */ const noteBlobs: Record> = {}; + const contentLengthColumn = blobStorageService.hasExternalContentColumns() + ? "contentLength" + : "LENGTH(COALESCE(blobs.content, ''))"; + type NoteContentLengthsRow = { noteId: string; blobId: string; @@ -127,7 +132,7 @@ function loadNeededInfoFromDatabase() { SELECT noteId, blobId, - LENGTH(content) AS length + ${contentLengthColumn} AS length FROM notes JOIN blobs USING(blobId) WHERE notes.isDeleted = 0`); @@ -153,7 +158,7 @@ function loadNeededInfoFromDatabase() { SELECT ownerId AS noteId, attachments.blobId, - LENGTH(content) AS length + ${contentLengthColumn} AS length FROM attachments JOIN notes ON attachments.ownerId = notes.noteId JOIN blobs ON attachments.blobId = blobs.blobId @@ -188,7 +193,7 @@ function loadNeededInfoFromDatabase() { SELECT noteId, revisions.blobId, - LENGTH(content) AS length, + ${contentLengthColumn} AS length, 1 AS isNoteRevision FROM notes JOIN revisions USING(noteId) @@ -198,7 +203,7 @@ function loadNeededInfoFromDatabase() { SELECT noteId, revisions.blobId, - LENGTH(content) AS length, + ${contentLengthColumn} AS length, 0 AS isNoteRevision -- it's attachment not counting towards revision count FROM notes JOIN revisions USING(noteId) @@ -252,21 +257,21 @@ function findResultsWithExpression(expression: Expression, searchContext: Search // Phase 1: Try exact matches first (without fuzzy matching) const exactResults = performSearch(expression, searchContext, false); - + // Check if we have sufficient high-quality results const minResultThreshold = 5; const minScoreForQuality = 10; // Minimum score to consider a result "high quality" - + const highQualityResults = exactResults.filter(result => result.score >= minScoreForQuality); - + // If we have enough high-quality exact matches, return them if (highQualityResults.length >= minResultThreshold) { return exactResults; } - + // Phase 2: Add fuzzy matching as fallback when exact matches are insufficient const fuzzyResults = performSearch(expression, searchContext, true); - + // Merge results, ensuring exact matches always rank higher than fuzzy matches return mergeExactAndFuzzyResults(exactResults, fuzzyResults); } @@ -326,10 +331,10 @@ function performSearch(expression: Expression, searchContext: SearchContext, ena function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: SearchResult[]): SearchResult[] { // Create a map of exact result note IDs for deduplication const exactNoteIds = new Set(exactResults.map(result => result.noteId)); - + // Add fuzzy results that aren't already in exact results const additionalFuzzyResults = fuzzyResults.filter(result => !exactNoteIds.has(result.noteId)); - + // Sort exact results by score (best exact matches first) exactResults.sort((a, b) => { if (a.score > b.score) { @@ -345,7 +350,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // Sort fuzzy results by score (best fuzzy matches first) additionalFuzzyResults.sort((a, b) => { if (a.score > b.score) { @@ -361,7 +366,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // CRITICAL: Always put exact matches before fuzzy matches, regardless of scores return [...exactResults, ...additionalFuzzyResults]; } @@ -417,10 +422,10 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear } // If the query starts with '#', it's a pure expression query. - // Don't use progressive search for these as they may have complex + // Don't use progressive search for these as they may have complex // ordering or other logic that shouldn't be interfered with. const isPureExpressionQuery = query.trim().startsWith('#'); - + if (isPureExpressionQuery) { // For pure expression queries, use standard search without progressive phases return performSearch(expression, searchContext, searchContext.enableFuzzyMatching); @@ -448,7 +453,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength try { let content = note.getContent(); - + if (!content || typeof content !== "string") { return ""; } @@ -489,7 +494,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength for (const token of searchTokens) { const normalizedToken = normalizeString(token.toLowerCase()); const matchIndex = normalizedContent.indexOf(normalizedToken); - + if (matchIndex !== -1) { // Center the snippet around the match snippetStart = Math.max(0, matchIndex - maxLength / 2); @@ -542,7 +547,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength } snippet = "..." + snippet; } - + if (snippetStart + maxLength < content.length) { const lastSpace = snippet.search(/\s[^\s]*$/); if (lastSpace > snippet.length - 20 && lastSpace > 0) { @@ -573,19 +578,19 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng } let matchingAttributes: Array<{name: string, value: string, type: string}> = []; - + // Look for attributes that match the search tokens for (const attr of attributes) { const attrName = attr.name?.toLowerCase() || ""; const attrValue = attr.value?.toLowerCase() || ""; const attrType = attr.type || ""; - + // Check if any search token matches the attribute name or value const hasMatch = searchTokens.some(token => { const normalizedToken = normalizeString(token.toLowerCase()); return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken); }); - + if (hasMatch) { matchingAttributes.push({ name: attr.name || "", @@ -611,20 +616,20 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng const targetTitle = targetNote ? targetNote.title : attr.value; line = `~${attr.name}="${targetTitle}"`; } - + if (line) { lines.push(line); } } let snippet = lines.join('\n'); - + // Apply length limit while preserving line structure if (snippet.length > maxLength) { // Try to truncate at word boundaries but keep lines intact const truncated = snippet.substring(0, maxLength); const lastNewline = truncated.lastIndexOf('\n'); - + if (lastNewline > maxLength / 2) { // If we can keep most content by truncating to last complete line snippet = truncated.substring(0, lastNewline); @@ -698,7 +703,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens for (const result of searchResults) { result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, ""); - + // Initialize highlighted content snippet if (result.contentSnippet) { // Escape HTML but preserve newlines for later conversion to
@@ -706,7 +711,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens // Remove any stray < { } that might interfere with our highlighting markers result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, ""); } - + // Initialize highlighted attribute snippet if (result.attributeSnippet) { // Escape HTML but preserve newlines for later conversion to
@@ -767,14 +772,14 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens if (result.highlightedNotePathTitle) { result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "").replace(/}/g, ""); } - + if (result.highlightedContentSnippet) { // Replace highlighting markers with HTML tags result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "").replace(/}/g, ""); // Convert newlines to
tags for HTML display result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "
"); } - + if (result.highlightedAttributeSnippet) { // Replace highlighting markers with HTML tags result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "").replace(/}/g, ""); diff --git a/apps/server/src/services/sync.ts b/apps/server/src/services/sync.ts index ef3bd6cba9..85725cc899 100644 --- a/apps/server/src/services/sync.ts +++ b/apps/server/src/services/sync.ts @@ -17,11 +17,12 @@ import ws from "./ws.js"; import entityChangesService from "./entity_changes.js"; import entityConstructor from "../becca/entity_constructor.js"; import becca from "../becca/becca.js"; -import type { EntityChange, EntityChangeRecord, EntityRow } from "@triliumnext/commons"; +import type { EntityChange, EntityChangeRecord, EntityRow, BlobRow } from "@triliumnext/commons"; import type { CookieJar, ExecOpts } from "./request_interface.js"; import setupService from "./setup.js"; import consistency_checks from "./consistency_checks.js"; import becca_loader from "../becca/becca_loader.js"; +import blobStorageService from "./blob-storage.js"; let proxyToggle = true; @@ -354,13 +355,15 @@ function getEntityChangeRow(entityChange: EntityChange) { return null; } - if (entityName === "blobs" && entityRow.content !== null) { - if (typeof entityRow.content === "string") { - entityRow.content = Buffer.from(entityRow.content, "utf-8"); - } + if (entityName === "blobs") { + const blobRow = entityRow as BlobRow; + const rawContent = blobStorageService.getContent(blobRow); + if (rawContent) { + const buffer = Buffer.isBuffer(rawContent) + ? rawContent + : Buffer.from(rawContent, "utf-8"); - if (entityRow.content) { - entityRow.content = entityRow.content.toString("base64"); + entityRow.content = buffer.toString("base64"); } } diff --git a/apps/server/src/services/ws.ts b/apps/server/src/services/ws.ts index 9dfcbc0198..2c79ee99bd 100644 --- a/apps/server/src/services/ws.ts +++ b/apps/server/src/services/ws.ts @@ -7,6 +7,7 @@ import config from "./config.js"; import syncMutexService from "./sync_mutex.js"; import protectedSessionService from "./protected_session.js"; import becca from "../becca/becca.js"; +import blobStorageService from "./blob-storage.js"; import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js"; import type { IncomingMessage, Server as HttpServer } from "http"; @@ -153,8 +154,12 @@ function fillInAdditionalProperties(entityChange: EntityChange) { entityChange.entity = becca.getAttachment(entityChange.entityId); if (!entityChange.entity) { + const contentLengthColumn = blobStorageService.hasExternalContentColumns() + ? "blobs.contentLength" + : "LENGTH(COALESCE(blobs.content, ''))"; + entityChange.entity = sql.getRow( - /*sql*/`SELECT attachments.*, LENGTH(blobs.content) AS contentLength + /*sql*/`SELECT attachments.*, ${contentLengthColumn} AS contentLength FROM attachments JOIN blobs USING (blobId) WHERE attachmentId = ?`, diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 338ba07aef..e420c7e9c7 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -28,7 +28,7 @@ const templateCache: Map = new Map(); */ export interface Result { header: string; - content: string | Buffer | undefined; + content: string | Buffer | null | undefined; /** Set to `true` if the provided content should be rendered as empty. */ isEmpty?: boolean; } diff --git a/apps/server/src/share/shaca/entities/sattachment.ts b/apps/server/src/share/shaca/entities/sattachment.ts index 11d3af0969..17873ea90b 100644 --- a/apps/server/src/share/shaca/entities/sattachment.ts +++ b/apps/server/src/share/shaca/entities/sattachment.ts @@ -2,6 +2,7 @@ import sql from "../../sql.js"; import utils from "../../../services/utils.js"; +import blobStorageService from "../../../services/blob-storage.js"; import AbstractShacaEntity from "./abstract_shaca_entity.js"; import type SNote from "./snote.js"; import type { Blob } from "../../../services/blob-interface.js"; @@ -37,7 +38,11 @@ class SAttachment extends AbstractShacaEntity { } getContent(silentNotFoundError = false) { - const row = sql.getRow>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); + const query = blobStorageService.hasExternalContentColumns() + ? /*sql*/`SELECT content, contentLocation FROM blobs WHERE blobId = ?` + : /*sql*/`SELECT content, 'internal' as contentLocation FROM blobs WHERE blobId = ?`; + + const row = sql.getRow>(query, [this.blobId]); if (!row) { if (silentNotFoundError) { @@ -47,7 +52,7 @@ class SAttachment extends AbstractShacaEntity { } } - const content = row.content; + const content = blobStorageService.getContent(row); if (this.hasStringContent()) { return content === null ? "" : content.toString("utf-8"); diff --git a/apps/server/src/share/shaca/entities/snote.ts b/apps/server/src/share/shaca/entities/snote.ts index 19dbd463e2..a729213952 100644 --- a/apps/server/src/share/shaca/entities/snote.ts +++ b/apps/server/src/share/shaca/entities/snote.ts @@ -5,6 +5,7 @@ import utils from "../../../services/utils.js"; import AbstractShacaEntity from "./abstract_shaca_entity.js"; import escape from "escape-html"; import type { Blob } from "../../../services/blob-interface.js"; +import blobStorageService from "../../../services/blob-storage.js"; import type SAttachment from "./sattachment.js"; import type SAttribute from "./sattribute.js"; import type SBranch from "./sbranch.js"; @@ -96,7 +97,11 @@ class SNote extends AbstractShacaEntity { } getContent(silentNotFoundError = false) { - const row = sql.getRow>(/*sql*/`SELECT content FROM blobs WHERE blobId = ?`, [this.blobId]); + const query = blobStorageService.hasExternalContentColumns() + ? /*sql*/`SELECT content, contentLocation FROM blobs WHERE blobId = ?` + : /*sql*/`SELECT content, 'internal' as contentLocation FROM blobs WHERE blobId = ?`; + + const row = sql.getRow>(query, [this.blobId]); if (!row) { if (silentNotFoundError) { @@ -106,7 +111,7 @@ class SNote extends AbstractShacaEntity { } } - const content = row.content; + const content = blobStorageService.getContent(row); if (this.hasStringContent()) { return content === null ? "" : content.toString("utf-8"); diff --git a/apps/server/vite.config.mts b/apps/server/vite.config.mts index 991d370bc5..14c67c823b 100644 --- a/apps/server/vite.config.mts +++ b/apps/server/vite.config.mts @@ -27,6 +27,7 @@ export default defineConfig(() => ({ provider: 'v8' as const, reporter: [ "text", "html" ] }, - pool: "vmForks" + pool: "vmForks", + hookTimeout: 30000 }, })); diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts index 5710cf84f7..4f914515aa 100644 --- a/packages/commons/src/lib/rows.ts +++ b/packages/commons/src/lib/rows.ts @@ -66,10 +66,13 @@ export interface EtapiTokenRow { isDeleted?: boolean; } +export type BlobContentLocation = 'internal' | `file://${string}`; + export interface BlobRow { blobId: string; - content: string | Buffer; + content: string | Buffer | null; contentLength: number; + contentLocation: BlobContentLocation; dateModified: string; utcDateModified: string; } From 89437772a19d83cb6fd2202cba641bf93723b6e7 Mon Sep 17 00:00:00 2001 From: Alex Dachin Date: Mon, 10 Nov 2025 18:19:22 +0700 Subject: [PATCH 2/4] Fix consistency_checks for externally stored blobs --- .../becca/entities/abstract_becca_entity.ts | 20 ++++++++++++++++++- apps/server/src/services/blob-storage.ts | 15 +++++++++++++- .../server/src/services/consistency_checks.ts | 3 ++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/server/src/becca/entities/abstract_becca_entity.ts b/apps/server/src/becca/entities/abstract_becca_entity.ts index 7b47fc0066..5e1e904c98 100644 --- a/apps/server/src/becca/entities/abstract_becca_entity.ts +++ b/apps/server/src/becca/entities/abstract_becca_entity.ts @@ -230,7 +230,25 @@ abstract class AbstractBeccaEntity> { const blobNeedsInsert = !sql.getValue("SELECT 1 FROM blobs WHERE blobId = ?", [newBlobId]); if (!blobNeedsInsert) { - return newBlobId; + if (!blobStorageService.hasExternalContentColumns()) { + // If no external storage support, safe to reuse blob + return newBlobId; + } + + // Self recover external blob if the file was deleted externally + const existingBlob = sql.getRow<{ contentLocation: BlobContentLocation }>(/*sql*/ + `SELECT contentLocation FROM blobs WHERE blobId = ?`, + [newBlobId] + ); + + const isInternalBlob = existingBlob?.contentLocation === 'internal'; + if (isInternalBlob || blobStorageService.externalFileExists(existingBlob.contentLocation)) { + // If external blob is still present, safe to reuse + return newBlobId; + } + + // External file is missing, recreate it + log.info(`External file missing for blob ${newBlobId}, recreating...`); } // Check if we should store this blob externally diff --git a/apps/server/src/services/blob-storage.ts b/apps/server/src/services/blob-storage.ts index 00b3fdc39b..3878151cb0 100644 --- a/apps/server/src/services/blob-storage.ts +++ b/apps/server/src/services/blob-storage.ts @@ -23,7 +23,7 @@ export class BlobStorageService { /** * Check if the external content columns (contentLocation, contentLength) exist in the blobs table. * This is cached for performance. - * Returns false before migration 234 has been applied (for example when applying older migrations). + * Returns false before migration 234 has been applied (aka when applying older migrations). */ hasExternalContentColumns(): boolean { if (!this._hasExternalContentColumns) { @@ -123,6 +123,19 @@ export class BlobStorageService { return blob.getContentLength(content) > config.ExternalBlobStorage.thresholdBytes; } + + /** + * Check if an external blob file exists + */ + externalFileExists(contentLocation: BlobContentLocation): boolean { + if (contentLocation === "internal" || !contentLocation.startsWith("file://")) { + return false; + } + + const relativePath = contentLocation.replace("file://", ""); + const filePath = path.join(this.externalBlobPath, relativePath); + return fs.existsSync(filePath); + } } export default new BlobStorageService(); diff --git a/apps/server/src/services/consistency_checks.ts b/apps/server/src/services/consistency_checks.ts index 7b4ba72adf..01b730084c 100644 --- a/apps/server/src/services/consistency_checks.ts +++ b/apps/server/src/services/consistency_checks.ts @@ -524,7 +524,8 @@ class ConsistencyChecks { JOIN blobs USING (blobId) WHERE isDeleted = 0 AND isProtected = 0 - AND content IS NULL`, + AND content IS NULL + AND (contentLocation IS NULL OR contentLocation = 'internal')`, ({ noteId, type, mime }) => { if (this.autoFix) { const note = becca.getNote(noteId); From f874d8bfb2d5be7c6fa33f0f49bc3fc60209487c Mon Sep 17 00:00:00 2001 From: Alex Dachin Date: Mon, 10 Nov 2025 20:48:02 +0700 Subject: [PATCH 3/4] Add documentation --- apps/server/src/assets/config-sample.ini | 15 ++ .../doc_notes/en/User Guide/!!!meta.json | 2 +- .../Configuration (config.ini or e.html | 28 +++ .../User Guide/Advanced Usage/Database.html | 4 + .../Advanced Usage/External Blob Storage.html | 59 +++++ .../Advanced Usage/Note source.html | 20 +- .../Notes/Attachments.html | 5 + .../Installation & Setup/Backup.html | 18 ++ .../Installation & Setup/Data directory.html | 2 + .../User Guide/Note Types/Canvas.html | 5 +- .../User Guide/Note Types/File.html | 85 ++++--- .../User Guide/Note Types/Mind Map.html | 51 ++--- .../User Guide/Note Types/Render Note.html | 17 +- .../Database/Database structure/blobs.md | 2 +- docs/User Guide/!!!meta.json | 209 ++++++++++++------ .../Configuration (config.ini or e.md | 7 + .../User Guide/Advanced Usage/Database.md | 2 + .../Advanced Usage/External Blob Storage.md | 47 ++++ .../Notes/Attachments.md | 4 + .../User Guide/Installation & Setup/Backup.md | 13 ++ .../Installation & Setup/Data directory.md | 1 + 21 files changed, 434 insertions(+), 162 deletions(-) create mode 100644 apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/External Blob Storage.html create mode 100644 docs/User Guide/User Guide/Advanced Usage/External Blob Storage.md diff --git a/apps/server/src/assets/config-sample.ini b/apps/server/src/assets/config-sample.ini index 41eb3d2b62..28c5596aba 100644 --- a/apps/server/src/assets/config-sample.ini +++ b/apps/server/src/assets/config-sample.ini @@ -67,3 +67,18 @@ oauthIssuerName= # Set the issuer icon for OAuth/OpenID authentication # This is the icon of the service that will be used to verify the user's identity oauthIssuerIcon= + +[ExternalBlobStorage] +# External blob storage allows large attachments to be stored as separate files +# instead of being embedded in the SQLite database. This can improve database +# performance and simplify backup strategies for large files. +# +# When enabled=false (default), all attachments are stored in the database +# When enabled=true, attachments larger than thresholdBytes are stored as files +# in the external-blobs directory within your data directory +enabled=false + +# Threshold size in bytes - attachments larger than this will be stored externally +# Default is 100KB (102400 bytes), which balances performance and flexibility +# See https://www.sqlite.org/intern-v-extern-blob.html for performance considerations +thresholdBytes=102400 diff --git a/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json b/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json index ef9d57e303..351e71b54f 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json +++ b/apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json @@ -1 +1 @@ -[{"id":"_help_BOCnjTMBCoxW","title":"Feature Highlights","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Feature Highlights"},{"name":"iconClass","value":"bx bx-star","type":"label"}]},{"id":"_help_Otzi9La2YAUX","title":"Installation & Setup","type":"book","attributes":[{"name":"iconClass","value":"bx bx-cog","type":"label"}],"children":[{"id":"_help_poXkQfguuA0U","title":"Desktop Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation"},{"name":"iconClass","value":"bx bx-desktop","type":"label"}],"children":[{"id":"_help_nRqcgfTb97uV","title":"Using the desktop application as a server","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation/Using the desktop application "},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_Rp0q8bSP6Ayl","title":"System Requirements","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation/System Requirements"},{"name":"iconClass","value":"bx bx-chip","type":"label"}]},{"id":"_help_Un4wj2Mak2Ky","title":"Nix flake","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation/Nix flake"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]}]},{"id":"_help_WOcw2SLH6tbX","title":"Server Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation"},{"name":"iconClass","value":"bx bx-server","type":"label"}],"children":[{"id":"_help_Dgg7bR3b6K9j","title":"1. Installing the server","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_3tW6mORuTHnB","title":"Packaged version for Linux","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Packaged version for Linux"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_rWX5eY045zbE","title":"Using Docker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Docker"},{"name":"iconClass","value":"bx bxl-docker","type":"label"}]},{"id":"_help_moVgBcoxE3EK","title":"On NixOS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/On NixOS"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_J1Bb6lVlwU5T","title":"Manually","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Manually"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]},{"id":"_help_DCmT6e7clMoP","title":"Using Kubernetes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Kubernetes"},{"name":"iconClass","value":"bx bxl-kubernetes","type":"label"}]},{"id":"_help_klCWNks3ReaQ","title":"Multiple server instances","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Multiple server instances"},{"name":"iconClass","value":"bx bxs-user-account","type":"label"}]}]},{"id":"_help_vcjrb3VVYPZI","title":"2. Reverse proxy","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_ud6MShXL4WpO","title":"Nginx","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Nginx"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_fDLvzOx29Pfg","title":"Apache using Docker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Apache using Docker"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_LLzSMXACKhUs","title":"Trusted proxy","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Trusted proxy"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_l2VkvOwUNfZj","title":"HTTPS (TLS)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/HTTPS (TLS)"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_0hzsNCP31IAB","title":"Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Authentication"},{"name":"iconClass","value":"bx bx-user","type":"label"}]},{"id":"_help_7DAiwaf8Z7Rz","title":"Multi-Factor Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Multi-Factor Authentication"},{"name":"iconClass","value":"bx bx-stopwatch","type":"label"}]},{"id":"_help_Un4wj2Mak2Ky","title":"Nix flake","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Nix flake.clone"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_yeEaYqosGLSh","title":"Third-party cloud hosting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Third-party cloud hosting"},{"name":"iconClass","value":"bx bx-cloud","type":"label"}]},{"id":"_help_iGTnKjubbXkA","title":"System Requirements","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/System Requirements"},{"name":"iconClass","value":"bx bx-chip","type":"label"}]}]},{"id":"_help_cbkrhQjrkKrh","title":"Synchronization","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Synchronization"},{"name":"iconClass","value":"bx bx-sync","type":"label"}]},{"id":"_help_RDslemsQ6gCp","title":"Mobile Frontend","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Mobile Frontend"},{"name":"iconClass","value":"bx bx-mobile-alt","type":"label"}]},{"id":"_help_MtPxeAWVAzMg","title":"Web Clipper","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Web Clipper"},{"name":"iconClass","value":"bx bx-paperclip","type":"label"}]},{"id":"_help_n1lujUxCwipy","title":"Upgrading TriliumNext","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Upgrading TriliumNext"},{"name":"iconClass","value":"bx bx-up-arrow-alt","type":"label"}]},{"id":"_help_ODY7qQn5m2FT","title":"Backup","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Backup"},{"name":"iconClass","value":"bx bx-hdd","type":"label"}]},{"id":"_help_tAassRL4RSQL","title":"Data directory","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Data directory"},{"name":"iconClass","value":"bx bx-folder-open","type":"label"}]}]},{"id":"_help_gh7bpGYxajRS","title":"Basic Concepts and Features","type":"book","attributes":[{"name":"iconClass","value":"bx bx-help-circle","type":"label"}],"children":[{"id":"_help_Vc8PjrjAGuOp","title":"UI Elements","type":"book","attributes":[{"name":"iconClass","value":"bx bx-window-alt","type":"label"}],"children":[{"id":"_help_x0JgW8UqGXvq","title":"Vertical and horizontal layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Vertical and horizontal layout"},{"name":"iconClass","value":"bx bxs-layout","type":"label"}]},{"id":"_help_x3i7MxGccDuM","title":"Global menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Global menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_oPVyFC7WL2Lp","title":"Note Tree","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree"},{"name":"iconClass","value":"bx bxs-tree-alt","type":"label"}],"children":[{"id":"_help_YtSN43OrfzaA","title":"Note tree contextual menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_yTjUdsOi4CIE","title":"Multiple selection","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Multiple selection"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_DvdZhoQZY9Yd","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]}]},{"id":"_help_BlN9DFI679QC","title":"Ribbon","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Ribbon"},{"name":"iconClass","value":"bx bx-dots-horizontal","type":"label"}]},{"id":"_help_3seOhtN8uLIY","title":"Tabs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Tabs"},{"name":"iconClass","value":"bx bx-dock-top","type":"label"}]},{"id":"_help_xYmIYSP6wE3F","title":"Launch Bar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Launch Bar"},{"name":"iconClass","value":"bx bx-sidebar","type":"label"}]},{"id":"_help_8YBEPzcpUgxw","title":"Note buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note buttons"},{"name":"iconClass","value":"bx bx-dots-vertical-rounded","type":"label"}]},{"id":"_help_4TIF1oA4VQRO","title":"Options","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Options"},{"name":"iconClass","value":"bx bx-cog","type":"label"}]},{"id":"_help_luNhaphA37EO","title":"Split View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Split View"},{"name":"iconClass","value":"bx bx-dock-right","type":"label"}]},{"id":"_help_XpOYSgsLkTJy","title":"Floating buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Floating buttons"},{"name":"iconClass","value":"bx bx-rectangle","type":"label"}]},{"id":"_help_RnaPdbciOfeq","title":"Right Sidebar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Right Sidebar"},{"name":"iconClass","value":"bx bxs-dock-right","type":"label"}]},{"id":"_help_r5JGHN99bVKn","title":"Recent Changes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Recent Changes"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_ny318J39E5Z0","title":"Zoom","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Zoom"},{"name":"iconClass","value":"bx bx-zoom-in","type":"label"}]},{"id":"_help_ZjLYv08Rp3qC","title":"Quick edit","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Quick edit"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_lgKX7r3aL30x","title":"Note Tooltip","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tooltip"},{"name":"iconClass","value":"bx bx-message-detail","type":"label"}]}]},{"id":"_help_BFs8mudNFgCS","title":"Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes"},{"name":"iconClass","value":"bx bx-notepad","type":"label"}],"children":[{"id":"_help_p9kXRFAkwN4o","title":"Note Icons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Icons"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_0vhv7lsOLy82","title":"Attachments","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Attachments"},{"name":"iconClass","value":"bx bx-paperclip","type":"label"}]},{"id":"_help_IakOLONlIfGI","title":"Cloning Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes"},{"name":"iconClass","value":"bx bx-duplicate","type":"label"}],"children":[{"id":"_help_TBwsyfadTA18","title":"Branch prefix","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes/Branch prefix"},{"name":"iconClass","value":"bx bx-rename","type":"label"}]}]},{"id":"_help_bwg0e8ewQMak","title":"Protected Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Protected Notes"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_MKmLg5x6xkor","title":"Archived Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Archived Notes"},{"name":"iconClass","value":"bx bx-box","type":"label"}]},{"id":"_help_vZWERwf8U3nx","title":"Note Revisions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_aGlEvb9hyDhS","title":"Sorting Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes"},{"name":"iconClass","value":"bx bx-sort-up","type":"label"}]},{"id":"_help_NRnIZmSMc5sj","title":"Printing & Exporting as PDF","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF"},{"name":"iconClass","value":"bx bx-printer","type":"label"}]},{"id":"_help_CoFPLs3dRlXc","title":"Read-Only Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Read-Only Notes"},{"name":"iconClass","value":"bx bx-edit-alt","type":"label"}]},{"id":"_help_0ESUbbAxVnoK","title":"Note List","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note List"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]}]},{"id":"_help_wArbEsdSae6g","title":"Navigation","type":"book","attributes":[{"name":"iconClass","value":"bx bx-navigation","type":"label"}],"children":[{"id":"_help_kBrnXNG3Hplm","title":"Tree Concepts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Tree Concepts"},{"name":"iconClass","value":"bx bx-pyramid","type":"label"}]},{"id":"_help_MMiBEQljMQh2","title":"Note Navigation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Navigation"},{"name":"iconClass","value":"bx bxs-navigation","type":"label"}]},{"id":"_help_Ms1nauBra7gq","title":"Quick search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_F1r9QtzQLZqm","title":"Jump to...","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to"},{"name":"iconClass","value":"bx bx-send","type":"label"}]},{"id":"_help_eIg8jdvaoNNd","title":"Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_u3YFHC9tQlpm","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmarks","type":"label"}]},{"id":"_help_OR8WJ7Iz9K4U","title":"Note Hoisting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Hoisting"},{"name":"iconClass","value":"bx bxs-chevrons-up","type":"label"}]},{"id":"_help_ZjLYv08Rp3qC","title":"Quick edit","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit.clone"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_9sRHySam5fXb","title":"Workspaces","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Workspaces"},{"name":"iconClass","value":"bx bx-door-open","type":"label"}]},{"id":"_help_xWtq5NUHOwql","title":"Similar Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Similar Notes"},{"name":"iconClass","value":"bx bx-bar-chart","type":"label"}]},{"id":"_help_McngOG2jbUWX","title":"Search in note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search in note"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]}]},{"id":"_help_A9Oc6YKKc65v","title":"Keyboard Shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_Wy267RK4M69c","title":"Themes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes"},{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_VbjZvtUek0Ln","title":"Theme Gallery","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes/Theme Gallery"},{"name":"iconClass","value":"bx bx-book-reader","type":"label"}]}]},{"id":"_help_mHbBMPDPkVV5","title":"Import & Export","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export"},{"name":"iconClass","value":"bx bx-import","type":"label"}],"children":[{"id":"_help_Oau6X9rCuegd","title":"Markdown","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}],"children":[{"id":"_help_rJ9grSgoExl9","title":"Supported syntax","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown/Supported syntax"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]}]},{"id":"_help_syuSEKf2rUGr","title":"Evernote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote"},{"name":"iconClass","value":"bx bx-window-open","type":"label"}]},{"id":"_help_GnhlmrATVqcH","title":"OneNote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/OneNote"},{"name":"iconClass","value":"bx bx-window-open","type":"label"}]}]},{"id":"_help_rC3pL2aptaRE","title":"Zen mode","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Zen mode"},{"name":"iconClass","value":"bx bxs-yin-yang","type":"label"}]}]},{"id":"_help_s3YCWHBfmYuM","title":"Quick Start","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Quick Start"},{"name":"iconClass","value":"bx bx-run","type":"label"}]},{"id":"_help_i6dbnitykE5D","title":"FAQ","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/FAQ"},{"name":"iconClass","value":"bx bx-question-mark","type":"label"}]},{"id":"_help_KSZ04uQ2D1St","title":"Note Types","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types"},{"name":"iconClass","value":"bx bx-edit","type":"label"}],"children":[{"id":"_help_iPIMuisry3hd","title":"Text","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text"},{"name":"iconClass","value":"bx bx-note","type":"label"}],"children":[{"id":"_help_NwBbFdNZ9h7O","title":"Block quotes & admonitions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Block quotes & admonitions"},{"name":"iconClass","value":"bx bx-info-circle","type":"label"}]},{"id":"_help_oSuaNgyyKnhu","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmark","type":"label"}]},{"id":"_help_veGu4faJErEM","title":"Content language & Right-to-left support","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Content language & Right-to-le"},{"name":"iconClass","value":"bx bx-align-right","type":"label"}]},{"id":"_help_2x0ZAX9ePtzV","title":"Cut to subnote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Cut to subnote"},{"name":"iconClass","value":"bx bx-cut","type":"label"}]},{"id":"_help_UYuUB1ZekNQU","title":"Developer-specific formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}],"children":[{"id":"_help_QxEyIjRBizuC","title":"Code blocks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting/Code blocks"},{"name":"iconClass","value":"bx bx-code","type":"label"}]}]},{"id":"_help_AgjCISero73a","title":"Footnotes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Footnotes"},{"name":"iconClass","value":"bx bx-bracket","type":"label"}]},{"id":"_help_nRhnJkTT8cPs","title":"Formatting toolbar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Formatting toolbar"},{"name":"iconClass","value":"bx bx-text","type":"label"}]},{"id":"_help_Gr6xFaF6ioJ5","title":"General formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/General formatting"},{"name":"iconClass","value":"bx bx-bold","type":"label"}]},{"id":"_help_AxshuNRegLAv","title":"Highlights list","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Highlights list"},{"name":"iconClass","value":"bx bx-highlight","type":"label"}]},{"id":"_help_mT0HEkOsz6i1","title":"Images","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images"},{"name":"iconClass","value":"bx bx-image-alt","type":"label"}],"children":[{"id":"_help_0Ofbk1aSuVRu","title":"Image references","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images/Image references"},{"name":"iconClass","value":"bx bxs-file-image","type":"label"}]}]},{"id":"_help_nBAXQFj20hS1","title":"Include Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Include Note"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_CohkqWQC1iBv","title":"Insert buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Insert buttons"},{"name":"iconClass","value":"bx bx-plus","type":"label"}]},{"id":"_help_oiVPnW8QfnvS","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_QEAPj01N5f7w","title":"Links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links"},{"name":"iconClass","value":"bx bx-link-alt","type":"label"}],"children":[{"id":"_help_3IDVtesTQ8ds","title":"External links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links/External links"},{"name":"iconClass","value":"bx bx-link-external","type":"label"}]},{"id":"_help_hrZ1D00cLbal","title":"Internal (reference) links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links/Internal (reference) links"},{"name":"iconClass","value":"bx bx-link","type":"label"}]}]},{"id":"_help_S6Xx8QIWTV66","title":"Lists","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Lists"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_QrtTYPmdd1qq","title":"Markdown-like formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Markdown-like formatting"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}]},{"id":"_help_YfYAtQBcfo5V","title":"Math Equations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Math Equations"},{"name":"iconClass","value":"bx bx-math","type":"label"}]},{"id":"_help_dEHYtoWWi8ct","title":"Other features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Other features"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_gLt3vA97tMcp","title":"Premium features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features"},{"name":"iconClass","value":"bx bx-star","type":"label"}],"children":[{"id":"_help_ZlN4nump6EbW","title":"Slash Commands","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features/Slash Commands"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_pwc194wlRzcH","title":"Text Snippets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features/Text Snippets"},{"name":"iconClass","value":"bx bx-align-left","type":"label"}]}]},{"id":"_help_BFvAtE74rbP6","title":"Table of contents","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Table of contents"},{"name":"iconClass","value":"bx bx-heading","type":"label"}]},{"id":"_help_NdowYOC1GFKS","title":"Tables","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Tables"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_6f9hih2hXXZk","title":"Code","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Code"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_m523cpzocqaD","title":"Saved Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Saved Search"},{"name":"iconClass","value":"bx bx-file-find","type":"label"}]},{"id":"_help_iRwzGnHPzonm","title":"Relation Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Relation Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_bdUJEHsAPYQR","title":"Note Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Note Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_HcABDtFCkbFN","title":"Render Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Render Note"},{"name":"iconClass","value":"bx bx-extension","type":"label"}]},{"id":"_help_s1aBHPd79XYj","title":"Mermaid Diagrams","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams"},{"name":"iconClass","value":"bx bx-selection","type":"label"}],"children":[{"id":"_help_RH6yLjjWJHof","title":"ELK layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams/ELK layout"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_WWgeUaBb7UfC","title":"Syntax reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://mermaid.js.org/intro/syntax-reference.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_grjYqerjn243","title":"Canvas","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Canvas"},{"name":"iconClass","value":"bx bx-pen","type":"label"}]},{"id":"_help_1vHRoWCEjj0L","title":"Web View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Web View"},{"name":"iconClass","value":"bx bx-globe-alt","type":"label"}]},{"id":"_help_gBbsAeiuUxI5","title":"Mind Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mind Map"},{"name":"iconClass","value":"bx bx-sitemap","type":"label"}]},{"id":"_help_W8vYD3Q1zjCR","title":"File","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/File"},{"name":"iconClass","value":"bx bx-file-blank","type":"label"}]}]},{"id":"_help_GTwFsgaA0lCt","title":"Collections","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections"},{"name":"iconClass","value":"bx bx-book","type":"label"}],"children":[{"id":"_help_xWbu3jpNWapp","title":"Calendar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Calendar"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_2FvYrpmOXm29","title":"Table","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Table"},{"name":"iconClass","value":"bx bx-table","type":"label"}]},{"id":"_help_CtBQqbwXDx1w","title":"Kanban Board","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Kanban Board"},{"name":"iconClass","value":"bx bx-columns","type":"label"}]},{"id":"_help_81SGnPGMk7Xc","title":"Geo Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Geo Map"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]},{"id":"_help_zP3PMqaG71Ct","title":"Presentation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Presentation"},{"name":"iconClass","value":"bx bx-slideshow","type":"label"}]},{"id":"_help_8QqnMzx393bx","title":"Grid View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Grid View"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_mULW0Q3VojwY","title":"List View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/List View"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]}]},{"id":"_help_BgmBlOIl72jZ","title":"Troubleshooting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting"},{"name":"iconClass","value":"bx bx-bug","type":"label"}],"children":[{"id":"_help_wy8So3yZZlH9","title":"Reporting issues","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Reporting issues"},{"name":"iconClass","value":"bx bx-bug-alt","type":"label"}]},{"id":"_help_x59R8J8KV5Bp","title":"Anonymized Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Anonymized Database"},{"name":"iconClass","value":"bx bx-low-vision","type":"label"}]},{"id":"_help_qzNzp9LYQyPT","title":"Error logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs"},{"name":"iconClass","value":"bx bx-comment-error","type":"label"}],"children":[{"id":"_help_bnyigUA2UK7s","title":"Backend (server) logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs/Backend (server) logs"},{"name":"iconClass","value":"bx bx-server","type":"label"}]},{"id":"_help_9yEHzMyFirZR","title":"Frontend logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs/Frontend logs"},{"name":"iconClass","value":"bx bx-window-alt","type":"label"}]}]},{"id":"_help_vdlYGAcpXAgc","title":"Synchronization fails with 504 Gateway Timeout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Synchronization fails with 504"},{"name":"iconClass","value":"bx bx-error","type":"label"}]},{"id":"_help_s8alTXmpFR61","title":"Refreshing the application","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Refreshing the application"},{"name":"iconClass","value":"bx bx-refresh","type":"label"}]}]},{"id":"_help_pKK96zzmvBGf","title":"Theme development","type":"book","attributes":[{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_7NfNr5pZpVKV","title":"Creating a custom theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Creating a custom theme"},{"name":"iconClass","value":"bx bxs-color","type":"label"}]},{"id":"_help_WFGzWeUK6arS","title":"Customize the Next theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Customize the Next theme"},{"name":"iconClass","value":"bx bx-news","type":"label"}]},{"id":"_help_WN5z4M8ASACJ","title":"Reference","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Reference"},{"name":"iconClass","value":"bx bx-book-open","type":"label"}]},{"id":"_help_AlhDUqhENtH7","title":"Custom app-wide CSS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Custom app-wide CSS"},{"name":"iconClass","value":"bx bxs-file-css","type":"label"}]}]},{"id":"_help_tC7s2alapj8V","title":"Advanced Usage","type":"book","attributes":[{"name":"iconClass","value":"bx bx-rocket","type":"label"}],"children":[{"id":"_help_zEY4DaJG4YT5","title":"Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes"},{"name":"iconClass","value":"bx bx-list-check","type":"label"}],"children":[{"id":"_help_HI6GBBIduIgv","title":"Labels","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Labels"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_Cq5X6iKQop6R","title":"Relations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Relations"},{"name":"iconClass","value":"bx bx-transfer","type":"label"}]},{"id":"_help_bwZpz2ajCEwO","title":"Attribute Inheritance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Attribute Inheritance"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_OFXdgB2nNk1F","title":"Promoted Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_KC1HB96bqqHX","title":"Templates","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Templates"},{"name":"iconClass","value":"bx bx-copy","type":"label"}]},{"id":"_help_BCkXAVs63Ttv","title":"Note Map (Link map, Tree map)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note Map (Link map, Tree map)"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_R9pX4DGra2Vt","title":"Sharing","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing"},{"name":"iconClass","value":"bx bx-share-alt","type":"label"}],"children":[{"id":"_help_Qjt68inQ2bRj","title":"Serving directly the content of a note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Serving directly the content o"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_ycBFjKrrwE9p","title":"Exporting static HTML for web publishing","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Exporting static HTML for web "},{"name":"iconClass","value":"bx bxs-file-html","type":"label"}]},{"id":"_help_sLIJ6f1dkJYW","title":"Reverse proxy configuration","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Reverse proxy configuration"},{"name":"iconClass","value":"bx bx-world","type":"label"}]}]},{"id":"_help_5668rwcirq1t","title":"Advanced Showcases","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases"},{"name":"iconClass","value":"bx bxs-component","type":"label"}],"children":[{"id":"_help_l0tKav7yLHGF","title":"Day Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Day Notes"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_R7abl2fc6Mxi","title":"Weight Tracker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Weight Tracker"},{"name":"iconClass","value":"bx bx-line-chart","type":"label"}]},{"id":"_help_xYjQUYhpbUEW","title":"Task Manager","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Task Manager"},{"name":"iconClass","value":"bx bx-calendar-check","type":"label"}]}]},{"id":"_help_J5Ex1ZrMbyJ6","title":"Custom Request Handler","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Request Handler"},{"name":"iconClass","value":"bx bx-globe","type":"label"}]},{"id":"_help_d3fAXQ2diepH","title":"Custom Resource Providers","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Resource Providers"},{"name":"iconClass","value":"bx bxs-file-plus","type":"label"}]},{"id":"_help_pgxEVkzLl1OP","title":"ETAPI (REST API)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/ETAPI (REST API)"},{"name":"iconClass","value":"bx bx-extension","type":"label"}],"children":[{"id":"_help_9qPsTWBorUhQ","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/rest-api/etapi/"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_47ZrP6FNuoG8","title":"Default Note Title","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Default Note Title"},{"name":"iconClass","value":"bx bx-edit-alt","type":"label"}]},{"id":"_help_wX4HbRucYSDD","title":"Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database"},{"name":"iconClass","value":"bx bx-data","type":"label"}],"children":[{"id":"_help_oyIAJ9PvvwHX","title":"Manually altering the database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database"},{"name":"iconClass","value":"bx bxs-edit","type":"label"}],"children":[{"id":"_help_YKWqdJhzi2VY","title":"SQL Console","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database/SQL Console"},{"name":"iconClass","value":"bx bx-data","type":"label"}]}]},{"id":"_help_6tZeKvSHEUiB","title":"Demo Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Demo Notes"},{"name":"iconClass","value":"bx bx-package","type":"label"}]}]},{"id":"_help_Gzjqa934BdH4","title":"Configuration (config.ini or environment variables)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or e"},{"name":"iconClass","value":"bx bx-cog","type":"label"}],"children":[{"id":"_help_c5xB8m4g2IY6","title":"Trilium instance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Trilium instance"},{"name":"iconClass","value":"bx bx-windows","type":"label"}]},{"id":"_help_LWtBjFej3wX3","title":"Cross-Origin Resource Sharing (CORS)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Cross-Origin Resource Sharing "},{"name":"iconClass","value":"bx bx-lock","type":"label"}]}]},{"id":"_help_ivYnonVFBxbQ","title":"Bulk Actions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Bulk Actions"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_4FahAwuGTAwC","title":"Note source","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note source"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_1YeN2MzFUluU","title":"Technologies used","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used"},{"name":"iconClass","value":"bx bx-pyramid","type":"label"}],"children":[{"id":"_help_MI26XDLSAlCD","title":"CKEditor","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/CKEditor"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_N4IDkixaDG9C","title":"MindElixir","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/MindElixir"},{"name":"iconClass","value":"bx bx-sitemap","type":"label"}]},{"id":"_help_H0mM1lTxF9JI","title":"Excalidraw","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Excalidraw"},{"name":"iconClass","value":"bx bx-pen","type":"label"}]},{"id":"_help_MQHyy2dIFgxS","title":"Leaflet","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Leaflet"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]}]},{"id":"_help_m1lbrzyKDaRB","title":"Note ID","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note ID"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_0vTSyvhPTAOz","title":"Internal API","type":"book","attributes":[{"name":"iconClass","value":"bx bxs-component","type":"label"}],"children":[{"id":"_help_z8O2VG4ZZJD7","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/rest-api/internal/"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_2mUhVmZK8RF3","title":"Hidden Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Hidden Notes"},{"name":"iconClass","value":"bx bx-hide","type":"label"}]},{"id":"_help_uYF7pmepw27K","title":"Metrics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Metrics"},{"name":"iconClass","value":"bx bxs-data","type":"label"}],"children":[{"id":"_help_bOP3TB56fL1V","title":"grafana-dashboard.json","type":"doc","attributes":[{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_64ZTlUPgEPtW","title":"Safe mode","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Safe mode"},{"name":"iconClass","value":"bx bxs-virus-block","type":"label"}]},{"id":"_help_HAIOFBoYIIdO","title":"Nightly release","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Nightly release"},{"name":"iconClass","value":"bx bx-moon","type":"label"}]},{"id":"_help_ZmT9ln8XJX2o","title":"Read-only database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Read-only database"},{"name":"iconClass","value":"bx bx-book-reader","type":"label"}]}]},{"id":"_help_GBBMSlVSOIGP","title":"AI","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI"},{"name":"iconClass","value":"bx bx-bot","type":"label"}],"children":[{"id":"_help_WkM7gsEUyCXs","title":"Providers","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Providers"},{"name":"iconClass","value":"bx bx-select-multiple","type":"label"}],"children":[{"id":"_help_7EdTxPADv95W","title":"Ollama","type":"book","attributes":[{"name":"iconClass","value":"bx bx-message-dots","type":"label"}],"children":[{"id":"_help_vvUCN7FDkq7G","title":"Installing Ollama","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Providers/Ollama/Installing Ollama"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ZavFigBX9AwP","title":"OpenAI","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Providers/OpenAI"},{"name":"iconClass","value":"bx bx-message-dots","type":"label"}]},{"id":"_help_e0lkirXEiSNc","title":"Anthropic","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Providers/Anthropic"},{"name":"iconClass","value":"bx bx-message-dots","type":"label"}]}]}]},{"id":"_help_CdNpE2pqjmI6","title":"Scripting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting"},{"name":"iconClass","value":"bx bxs-file-js","type":"label"}],"children":[{"id":"_help_yIhgI5H7A2Sm","title":"Frontend Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics"},{"name":"iconClass","value":"bx bx-window","type":"label"}],"children":[{"id":"_help_MgibgPcfeuGz","title":"Custom Widgets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets"},{"name":"iconClass","value":"bx bxs-widget","type":"label"}],"children":[{"id":"_help_YNxAqkI5Kg1M","title":"Word count widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_SynTBQiBsdYJ","title":"Widget Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_M8IppdwVHSjG","title":"Right pane widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Right pane widget"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_VqGQnnPGnqAU","title":"CSS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/CSS"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_es8OU2GuguFU","title":"Examples","type":"book","attributes":[{"name":"iconClass","value":"bx bx-code-alt","type":"label"}],"children":[{"id":"_help_TjLYAo3JMO8X","title":"\"New Task\" launcher button","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Examples/New Task launcher button"},{"name":"iconClass","value":"bx bx-task","type":"label"}]},{"id":"_help_7kZPMD0uFwkH","title":"Downloading responses from Google Forms","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Examples/Downloading responses from Goo"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_DL92EjAaXT26","title":"Using promoted attributes to configure scripts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Examples/Using promoted attributes to c"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]},{"id":"_help_SPirpZypehBG","title":"Backend scripts","type":"book","attributes":[{"name":"iconClass","value":"bx bx-server","type":"label"}],"children":[{"id":"_help_fZ2IGYFXjkEy","title":"Server-side imports","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Backend scripts/Server-side imports"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_GPERMystNGTB","title":"Events","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Backend scripts/Events"},{"name":"iconClass","value":"bx bx-rss","type":"label"}]}]},{"id":"_help_GLks18SNjxmC","title":"Script API","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Script API"},{"name":"iconClass","value":"bx bx-code-curly","type":"label"}],"children":[{"id":"_help_Q2z6av6JZVWm","title":"Frontend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/script-api/frontend"},{"name":"iconClass","value":"bx bx-folder","type":"label"}],"enforceAttributes":true,"children":[{"id":"_help_habiZ3HU8Kw8","title":"FNote","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/script-api/frontend/interfaces/FNote.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_MEtfsqa5VwNi","title":"Backend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/script-api/backend"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_vElnKeDNPSVl","title":"Logging","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Logging"},{"name":"iconClass","value":"bx bx-terminal","type":"label"}]}]},{"id":"_help_Fm0j45KqyHpU","title":"Miscellaneous","type":"book","attributes":[{"name":"iconClass","value":"bx bx-info-circle","type":"label"}],"children":[{"id":"_help_WFbFXrgnDyyU","title":"Privacy Policy","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Miscellaneous/Privacy Policy"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_NcsmUYZRWEW4","title":"Patterns of personal knowledge","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Miscellaneous/Patterns of personal knowledge"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}] \ No newline at end of file +[{"id":"_help_BOCnjTMBCoxW","title":"Feature Highlights","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Feature Highlights"},{"name":"iconClass","value":"bx bx-star","type":"label"}]},{"id":"_help_Otzi9La2YAUX","title":"Installation & Setup","type":"book","attributes":[{"name":"iconClass","value":"bx bx-cog","type":"label"}],"children":[{"id":"_help_poXkQfguuA0U","title":"Desktop Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation"},{"name":"iconClass","value":"bx bx-desktop","type":"label"}],"children":[{"id":"_help_nRqcgfTb97uV","title":"Using the desktop application as a server","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation/Using the desktop application "},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_Rp0q8bSP6Ayl","title":"System Requirements","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation/System Requirements"},{"name":"iconClass","value":"bx bx-chip","type":"label"}]},{"id":"_help_Un4wj2Mak2Ky","title":"Nix flake","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Desktop Installation/Nix flake"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]}]},{"id":"_help_WOcw2SLH6tbX","title":"Server Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation"},{"name":"iconClass","value":"bx bx-server","type":"label"}],"children":[{"id":"_help_Dgg7bR3b6K9j","title":"1. Installing the server","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_3tW6mORuTHnB","title":"Packaged version for Linux","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Packaged version for Linux"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_rWX5eY045zbE","title":"Using Docker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Docker"},{"name":"iconClass","value":"bx bxl-docker","type":"label"}]},{"id":"_help_moVgBcoxE3EK","title":"On NixOS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/On NixOS"},{"name":"iconClass","value":"bx bxl-tux","type":"label"}]},{"id":"_help_J1Bb6lVlwU5T","title":"Manually","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Manually"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]},{"id":"_help_DCmT6e7clMoP","title":"Using Kubernetes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Using Kubernetes"},{"name":"iconClass","value":"bx bxl-kubernetes","type":"label"}]},{"id":"_help_klCWNks3ReaQ","title":"Multiple server instances","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/1. Installing the server/Multiple server instances"},{"name":"iconClass","value":"bx bxs-user-account","type":"label"}]}]},{"id":"_help_vcjrb3VVYPZI","title":"2. Reverse proxy","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_ud6MShXL4WpO","title":"Nginx","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Nginx"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_fDLvzOx29Pfg","title":"Apache using Docker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Apache using Docker"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_LLzSMXACKhUs","title":"Trusted proxy","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Trusted proxy"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_l2VkvOwUNfZj","title":"HTTPS (TLS)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/HTTPS (TLS)"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_0hzsNCP31IAB","title":"Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Authentication"},{"name":"iconClass","value":"bx bx-user","type":"label"}]},{"id":"_help_7DAiwaf8Z7Rz","title":"Multi-Factor Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Multi-Factor Authentication"},{"name":"iconClass","value":"bx bx-stopwatch","type":"label"}]},{"id":"_help_Un4wj2Mak2Ky","title":"Nix flake","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Nix flake.clone"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_yeEaYqosGLSh","title":"Third-party cloud hosting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/Third-party cloud hosting"},{"name":"iconClass","value":"bx bx-cloud","type":"label"}]},{"id":"_help_iGTnKjubbXkA","title":"System Requirements","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/System Requirements"},{"name":"iconClass","value":"bx bx-chip","type":"label"}]}]},{"id":"_help_cbkrhQjrkKrh","title":"Synchronization","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Synchronization"},{"name":"iconClass","value":"bx bx-sync","type":"label"}]},{"id":"_help_RDslemsQ6gCp","title":"Mobile Frontend","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Mobile Frontend"},{"name":"iconClass","value":"bx bx-mobile-alt","type":"label"}]},{"id":"_help_MtPxeAWVAzMg","title":"Web Clipper","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Web Clipper"},{"name":"iconClass","value":"bx bx-paperclip","type":"label"}]},{"id":"_help_n1lujUxCwipy","title":"Upgrading TriliumNext","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Upgrading TriliumNext"},{"name":"iconClass","value":"bx bx-up-arrow-alt","type":"label"}]},{"id":"_help_ODY7qQn5m2FT","title":"Backup","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Backup"},{"name":"iconClass","value":"bx bx-hdd","type":"label"}]},{"id":"_help_tAassRL4RSQL","title":"Data directory","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Data directory"},{"name":"iconClass","value":"bx bx-folder-open","type":"label"}]}]},{"id":"_help_gh7bpGYxajRS","title":"Basic Concepts and Features","type":"book","attributes":[{"name":"iconClass","value":"bx bx-help-circle","type":"label"}],"children":[{"id":"_help_Vc8PjrjAGuOp","title":"UI Elements","type":"book","attributes":[{"name":"iconClass","value":"bx bx-window-alt","type":"label"}],"children":[{"id":"_help_x0JgW8UqGXvq","title":"Vertical and horizontal layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Vertical and horizontal layout"},{"name":"iconClass","value":"bx bxs-layout","type":"label"}]},{"id":"_help_x3i7MxGccDuM","title":"Global menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Global menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_oPVyFC7WL2Lp","title":"Note Tree","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree"},{"name":"iconClass","value":"bx bxs-tree-alt","type":"label"}],"children":[{"id":"_help_YtSN43OrfzaA","title":"Note tree contextual menu","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Note tree contextual menu"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_yTjUdsOi4CIE","title":"Multiple selection","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Multiple selection"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_DvdZhoQZY9Yd","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tree/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]}]},{"id":"_help_BlN9DFI679QC","title":"Ribbon","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Ribbon"},{"name":"iconClass","value":"bx bx-dots-horizontal","type":"label"}]},{"id":"_help_3seOhtN8uLIY","title":"Tabs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Tabs"},{"name":"iconClass","value":"bx bx-dock-top","type":"label"}]},{"id":"_help_xYmIYSP6wE3F","title":"Launch Bar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Launch Bar"},{"name":"iconClass","value":"bx bx-sidebar","type":"label"}]},{"id":"_help_8YBEPzcpUgxw","title":"Note buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note buttons"},{"name":"iconClass","value":"bx bx-dots-vertical-rounded","type":"label"}]},{"id":"_help_4TIF1oA4VQRO","title":"Options","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Options"},{"name":"iconClass","value":"bx bx-cog","type":"label"}]},{"id":"_help_luNhaphA37EO","title":"Split View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Split View"},{"name":"iconClass","value":"bx bx-dock-right","type":"label"}]},{"id":"_help_XpOYSgsLkTJy","title":"Floating buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Floating buttons"},{"name":"iconClass","value":"bx bx-rectangle","type":"label"}]},{"id":"_help_RnaPdbciOfeq","title":"Right Sidebar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Right Sidebar"},{"name":"iconClass","value":"bx bxs-dock-right","type":"label"}]},{"id":"_help_r5JGHN99bVKn","title":"Recent Changes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Recent Changes"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_ny318J39E5Z0","title":"Zoom","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Zoom"},{"name":"iconClass","value":"bx bx-zoom-in","type":"label"}]},{"id":"_help_ZjLYv08Rp3qC","title":"Quick edit","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Quick edit"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_lgKX7r3aL30x","title":"Note Tooltip","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/UI Elements/Note Tooltip"},{"name":"iconClass","value":"bx bx-message-detail","type":"label"}]}]},{"id":"_help_BFs8mudNFgCS","title":"Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes"},{"name":"iconClass","value":"bx bx-notepad","type":"label"}],"children":[{"id":"_help_p9kXRFAkwN4o","title":"Note Icons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Icons"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_0vhv7lsOLy82","title":"Attachments","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Attachments"},{"name":"iconClass","value":"bx bx-paperclip","type":"label"}]},{"id":"_help_IakOLONlIfGI","title":"Cloning Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes"},{"name":"iconClass","value":"bx bx-duplicate","type":"label"}],"children":[{"id":"_help_TBwsyfadTA18","title":"Branch prefix","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Cloning Notes/Branch prefix"},{"name":"iconClass","value":"bx bx-rename","type":"label"}]}]},{"id":"_help_bwg0e8ewQMak","title":"Protected Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Protected Notes"},{"name":"iconClass","value":"bx bx-lock-alt","type":"label"}]},{"id":"_help_MKmLg5x6xkor","title":"Archived Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Archived Notes"},{"name":"iconClass","value":"bx bx-box","type":"label"}]},{"id":"_help_vZWERwf8U3nx","title":"Note Revisions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note Revisions"},{"name":"iconClass","value":"bx bx-history","type":"label"}]},{"id":"_help_aGlEvb9hyDhS","title":"Sorting Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes"},{"name":"iconClass","value":"bx bx-sort-up","type":"label"}]},{"id":"_help_NRnIZmSMc5sj","title":"Printing & Exporting as PDF","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Printing & Exporting as PDF"},{"name":"iconClass","value":"bx bx-printer","type":"label"}]},{"id":"_help_CoFPLs3dRlXc","title":"Read-Only Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Read-Only Notes"},{"name":"iconClass","value":"bx bx-edit-alt","type":"label"}]},{"id":"_help_0ESUbbAxVnoK","title":"Note List","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Note List"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]}]},{"id":"_help_wArbEsdSae6g","title":"Navigation","type":"book","attributes":[{"name":"iconClass","value":"bx bx-navigation","type":"label"}],"children":[{"id":"_help_kBrnXNG3Hplm","title":"Tree Concepts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Tree Concepts"},{"name":"iconClass","value":"bx bx-pyramid","type":"label"}]},{"id":"_help_MMiBEQljMQh2","title":"Note Navigation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Navigation"},{"name":"iconClass","value":"bx bxs-navigation","type":"label"}]},{"id":"_help_Ms1nauBra7gq","title":"Quick search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_F1r9QtzQLZqm","title":"Jump to...","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Jump to"},{"name":"iconClass","value":"bx bx-send","type":"label"}]},{"id":"_help_eIg8jdvaoNNd","title":"Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]},{"id":"_help_u3YFHC9tQlpm","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmarks","type":"label"}]},{"id":"_help_OR8WJ7Iz9K4U","title":"Note Hoisting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Note Hoisting"},{"name":"iconClass","value":"bx bxs-chevrons-up","type":"label"}]},{"id":"_help_ZjLYv08Rp3qC","title":"Quick edit","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Quick edit.clone"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_9sRHySam5fXb","title":"Workspaces","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Workspaces"},{"name":"iconClass","value":"bx bx-door-open","type":"label"}]},{"id":"_help_xWtq5NUHOwql","title":"Similar Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Similar Notes"},{"name":"iconClass","value":"bx bx-bar-chart","type":"label"}]},{"id":"_help_McngOG2jbUWX","title":"Search in note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Navigation/Search in note"},{"name":"iconClass","value":"bx bx-search-alt-2","type":"label"}]}]},{"id":"_help_A9Oc6YKKc65v","title":"Keyboard Shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Keyboard Shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_Wy267RK4M69c","title":"Themes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes"},{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_VbjZvtUek0Ln","title":"Theme Gallery","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Themes/Theme Gallery"},{"name":"iconClass","value":"bx bx-book-reader","type":"label"}]}]},{"id":"_help_mHbBMPDPkVV5","title":"Import & Export","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export"},{"name":"iconClass","value":"bx bx-import","type":"label"}],"children":[{"id":"_help_Oau6X9rCuegd","title":"Markdown","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}],"children":[{"id":"_help_rJ9grSgoExl9","title":"Supported syntax","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Markdown/Supported syntax"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}]}]},{"id":"_help_syuSEKf2rUGr","title":"Evernote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote"},{"name":"iconClass","value":"bx bx-window-open","type":"label"}]},{"id":"_help_GnhlmrATVqcH","title":"OneNote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Import & Export/OneNote"},{"name":"iconClass","value":"bx bx-window-open","type":"label"}]}]},{"id":"_help_rC3pL2aptaRE","title":"Zen mode","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Zen mode"},{"name":"iconClass","value":"bx bxs-yin-yang","type":"label"}]}]},{"id":"_help_s3YCWHBfmYuM","title":"Quick Start","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Quick Start"},{"name":"iconClass","value":"bx bx-run","type":"label"}]},{"id":"_help_i6dbnitykE5D","title":"FAQ","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/FAQ"},{"name":"iconClass","value":"bx bx-question-mark","type":"label"}]},{"id":"_help_KSZ04uQ2D1St","title":"Note Types","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types"},{"name":"iconClass","value":"bx bx-edit","type":"label"}],"children":[{"id":"_help_iPIMuisry3hd","title":"Text","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text"},{"name":"iconClass","value":"bx bx-note","type":"label"}],"children":[{"id":"_help_NwBbFdNZ9h7O","title":"Block quotes & admonitions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Block quotes & admonitions"},{"name":"iconClass","value":"bx bx-info-circle","type":"label"}]},{"id":"_help_oSuaNgyyKnhu","title":"Bookmarks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Bookmarks"},{"name":"iconClass","value":"bx bx-bookmark","type":"label"}]},{"id":"_help_veGu4faJErEM","title":"Content language & Right-to-left support","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Content language & Right-to-le"},{"name":"iconClass","value":"bx bx-align-right","type":"label"}]},{"id":"_help_2x0ZAX9ePtzV","title":"Cut to subnote","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Cut to subnote"},{"name":"iconClass","value":"bx bx-cut","type":"label"}]},{"id":"_help_UYuUB1ZekNQU","title":"Developer-specific formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting"},{"name":"iconClass","value":"bx bx-code-alt","type":"label"}],"children":[{"id":"_help_QxEyIjRBizuC","title":"Code blocks","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Developer-specific formatting/Code blocks"},{"name":"iconClass","value":"bx bx-code","type":"label"}]}]},{"id":"_help_AgjCISero73a","title":"Footnotes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Footnotes"},{"name":"iconClass","value":"bx bx-bracket","type":"label"}]},{"id":"_help_nRhnJkTT8cPs","title":"Formatting toolbar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Formatting toolbar"},{"name":"iconClass","value":"bx bx-text","type":"label"}]},{"id":"_help_Gr6xFaF6ioJ5","title":"General formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/General formatting"},{"name":"iconClass","value":"bx bx-bold","type":"label"}]},{"id":"_help_AxshuNRegLAv","title":"Highlights list","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Highlights list"},{"name":"iconClass","value":"bx bx-highlight","type":"label"}]},{"id":"_help_mT0HEkOsz6i1","title":"Images","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images"},{"name":"iconClass","value":"bx bx-image-alt","type":"label"}],"children":[{"id":"_help_0Ofbk1aSuVRu","title":"Image references","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Images/Image references"},{"name":"iconClass","value":"bx bxs-file-image","type":"label"}]}]},{"id":"_help_nBAXQFj20hS1","title":"Include Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Include Note"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_CohkqWQC1iBv","title":"Insert buttons","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Insert buttons"},{"name":"iconClass","value":"bx bx-plus","type":"label"}]},{"id":"_help_oiVPnW8QfnvS","title":"Keyboard shortcuts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Keyboard shortcuts"},{"name":"iconClass","value":"bx bxs-keyboard","type":"label"}]},{"id":"_help_QEAPj01N5f7w","title":"Links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links"},{"name":"iconClass","value":"bx bx-link-alt","type":"label"}],"children":[{"id":"_help_3IDVtesTQ8ds","title":"External links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links/External links"},{"name":"iconClass","value":"bx bx-link-external","type":"label"}]},{"id":"_help_hrZ1D00cLbal","title":"Internal (reference) links","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Links/Internal (reference) links"},{"name":"iconClass","value":"bx bx-link","type":"label"}]}]},{"id":"_help_S6Xx8QIWTV66","title":"Lists","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Lists"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_QrtTYPmdd1qq","title":"Markdown-like formatting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Markdown-like formatting"},{"name":"iconClass","value":"bx bxl-markdown","type":"label"}]},{"id":"_help_YfYAtQBcfo5V","title":"Math Equations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Math Equations"},{"name":"iconClass","value":"bx bx-math","type":"label"}]},{"id":"_help_dEHYtoWWi8ct","title":"Other features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Other features"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_gLt3vA97tMcp","title":"Premium features","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features"},{"name":"iconClass","value":"bx bx-star","type":"label"}],"children":[{"id":"_help_ZlN4nump6EbW","title":"Slash Commands","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features/Slash Commands"},{"name":"iconClass","value":"bx bx-menu","type":"label"}]},{"id":"_help_pwc194wlRzcH","title":"Text Snippets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Premium features/Text Snippets"},{"name":"iconClass","value":"bx bx-align-left","type":"label"}]}]},{"id":"_help_BFvAtE74rbP6","title":"Table of contents","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Table of contents"},{"name":"iconClass","value":"bx bx-heading","type":"label"}]},{"id":"_help_NdowYOC1GFKS","title":"Tables","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Text/Tables"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_6f9hih2hXXZk","title":"Code","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Code"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_m523cpzocqaD","title":"Saved Search","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Saved Search"},{"name":"iconClass","value":"bx bx-file-find","type":"label"}]},{"id":"_help_iRwzGnHPzonm","title":"Relation Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Relation Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_bdUJEHsAPYQR","title":"Note Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Note Map"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_HcABDtFCkbFN","title":"Render Note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Render Note"},{"name":"iconClass","value":"bx bx-extension","type":"label"}]},{"id":"_help_s1aBHPd79XYj","title":"Mermaid Diagrams","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams"},{"name":"iconClass","value":"bx bx-selection","type":"label"}],"children":[{"id":"_help_RH6yLjjWJHof","title":"ELK layout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mermaid Diagrams/ELK layout"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_WWgeUaBb7UfC","title":"Syntax reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://mermaid.js.org/intro/syntax-reference.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_grjYqerjn243","title":"Canvas","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Canvas"},{"name":"iconClass","value":"bx bx-pen","type":"label"}]},{"id":"_help_1vHRoWCEjj0L","title":"Web View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Web View"},{"name":"iconClass","value":"bx bx-globe-alt","type":"label"}]},{"id":"_help_gBbsAeiuUxI5","title":"Mind Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Mind Map"},{"name":"iconClass","value":"bx bx-sitemap","type":"label"}]},{"id":"_help_W8vYD3Q1zjCR","title":"File","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/File"},{"name":"iconClass","value":"bx bx-file-blank","type":"label"}]}]},{"id":"_help_GTwFsgaA0lCt","title":"Collections","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections"},{"name":"iconClass","value":"bx bx-book","type":"label"}],"children":[{"id":"_help_xWbu3jpNWapp","title":"Calendar","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Calendar"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_2FvYrpmOXm29","title":"Table","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Table"},{"name":"iconClass","value":"bx bx-table","type":"label"}]},{"id":"_help_CtBQqbwXDx1w","title":"Kanban Board","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Kanban Board"},{"name":"iconClass","value":"bx bx-columns","type":"label"}]},{"id":"_help_81SGnPGMk7Xc","title":"Geo Map","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Geo Map"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]},{"id":"_help_zP3PMqaG71Ct","title":"Presentation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Presentation"},{"name":"iconClass","value":"bx bx-slideshow","type":"label"}]},{"id":"_help_8QqnMzx393bx","title":"Grid View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/Grid View"},{"name":"iconClass","value":"bx bxs-grid","type":"label"}]},{"id":"_help_mULW0Q3VojwY","title":"List View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Collections/List View"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]}]},{"id":"_help_BgmBlOIl72jZ","title":"Troubleshooting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting"},{"name":"iconClass","value":"bx bx-bug","type":"label"}],"children":[{"id":"_help_wy8So3yZZlH9","title":"Reporting issues","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Reporting issues"},{"name":"iconClass","value":"bx bx-bug-alt","type":"label"}]},{"id":"_help_x59R8J8KV5Bp","title":"Anonymized Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Anonymized Database"},{"name":"iconClass","value":"bx bx-low-vision","type":"label"}]},{"id":"_help_qzNzp9LYQyPT","title":"Error logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs"},{"name":"iconClass","value":"bx bx-comment-error","type":"label"}],"children":[{"id":"_help_bnyigUA2UK7s","title":"Backend (server) logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs/Backend (server) logs"},{"name":"iconClass","value":"bx bx-server","type":"label"}]},{"id":"_help_9yEHzMyFirZR","title":"Frontend logs","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Error logs/Frontend logs"},{"name":"iconClass","value":"bx bx-window-alt","type":"label"}]}]},{"id":"_help_vdlYGAcpXAgc","title":"Synchronization fails with 504 Gateway Timeout","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Synchronization fails with 504"},{"name":"iconClass","value":"bx bx-error","type":"label"}]},{"id":"_help_s8alTXmpFR61","title":"Refreshing the application","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Troubleshooting/Refreshing the application"},{"name":"iconClass","value":"bx bx-refresh","type":"label"}]}]},{"id":"_help_pKK96zzmvBGf","title":"Theme development","type":"book","attributes":[{"name":"iconClass","value":"bx bx-palette","type":"label"}],"children":[{"id":"_help_7NfNr5pZpVKV","title":"Creating a custom theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Creating a custom theme"},{"name":"iconClass","value":"bx bxs-color","type":"label"}]},{"id":"_help_WFGzWeUK6arS","title":"Customize the Next theme","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Customize the Next theme"},{"name":"iconClass","value":"bx bx-news","type":"label"}]},{"id":"_help_WN5z4M8ASACJ","title":"Reference","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Reference"},{"name":"iconClass","value":"bx bx-book-open","type":"label"}]},{"id":"_help_AlhDUqhENtH7","title":"Custom app-wide CSS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Theme development/Custom app-wide CSS"},{"name":"iconClass","value":"bx bxs-file-css","type":"label"}]}]},{"id":"_help_tC7s2alapj8V","title":"Advanced Usage","type":"book","attributes":[{"name":"iconClass","value":"bx bx-rocket","type":"label"}],"children":[{"id":"_help_zEY4DaJG4YT5","title":"Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes"},{"name":"iconClass","value":"bx bx-list-check","type":"label"}],"children":[{"id":"_help_HI6GBBIduIgv","title":"Labels","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Labels"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_Cq5X6iKQop6R","title":"Relations","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Relations"},{"name":"iconClass","value":"bx bx-transfer","type":"label"}]},{"id":"_help_bwZpz2ajCEwO","title":"Attribute Inheritance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Attribute Inheritance"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_OFXdgB2nNk1F","title":"Promoted Attributes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Attributes/Promoted Attributes"},{"name":"iconClass","value":"bx bx-table","type":"label"}]}]},{"id":"_help_KC1HB96bqqHX","title":"Templates","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Templates"},{"name":"iconClass","value":"bx bx-copy","type":"label"}]},{"id":"_help_BCkXAVs63Ttv","title":"Note Map (Link map, Tree map)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note Map (Link map, Tree map)"},{"name":"iconClass","value":"bx bxs-network-chart","type":"label"}]},{"id":"_help_R9pX4DGra2Vt","title":"Sharing","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing"},{"name":"iconClass","value":"bx bx-share-alt","type":"label"}],"children":[{"id":"_help_Qjt68inQ2bRj","title":"Serving directly the content of a note","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Serving directly the content o"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_ycBFjKrrwE9p","title":"Exporting static HTML for web publishing","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Exporting static HTML for web "},{"name":"iconClass","value":"bx bxs-file-html","type":"label"}]},{"id":"_help_sLIJ6f1dkJYW","title":"Reverse proxy configuration","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Sharing/Reverse proxy configuration"},{"name":"iconClass","value":"bx bx-world","type":"label"}]}]},{"id":"_help_5668rwcirq1t","title":"Advanced Showcases","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases"},{"name":"iconClass","value":"bx bxs-component","type":"label"}],"children":[{"id":"_help_l0tKav7yLHGF","title":"Day Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Day Notes"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_R7abl2fc6Mxi","title":"Weight Tracker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Weight Tracker"},{"name":"iconClass","value":"bx bx-line-chart","type":"label"}]},{"id":"_help_xYjQUYhpbUEW","title":"Task Manager","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Advanced Showcases/Task Manager"},{"name":"iconClass","value":"bx bx-calendar-check","type":"label"}]}]},{"id":"_help_J5Ex1ZrMbyJ6","title":"Custom Request Handler","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Request Handler"},{"name":"iconClass","value":"bx bx-globe","type":"label"}]},{"id":"_help_d3fAXQ2diepH","title":"Custom Resource Providers","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Custom Resource Providers"},{"name":"iconClass","value":"bx bxs-file-plus","type":"label"}]},{"id":"_help_pgxEVkzLl1OP","title":"ETAPI (REST API)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/ETAPI (REST API)"},{"name":"iconClass","value":"bx bx-extension","type":"label"}],"children":[{"id":"_help_9qPsTWBorUhQ","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/rest-api/etapi/"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_xB9eL2mK8vWp","title":"External Blob Storage","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/External Blob Storage"},{"name":"iconClass","value":"bx bx-hdd","type":"label"}]},{"id":"_help_47ZrP6FNuoG8","title":"Default Note Title","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Default Note Title"},{"name":"iconClass","value":"bx bx-edit-alt","type":"label"}]},{"id":"_help_wX4HbRucYSDD","title":"Database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database"},{"name":"iconClass","value":"bx bx-data","type":"label"}],"children":[{"id":"_help_oyIAJ9PvvwHX","title":"Manually altering the database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database"},{"name":"iconClass","value":"bx bxs-edit","type":"label"}],"children":[{"id":"_help_YKWqdJhzi2VY","title":"SQL Console","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Manually altering the database/SQL Console"},{"name":"iconClass","value":"bx bx-data","type":"label"}]}]},{"id":"_help_6tZeKvSHEUiB","title":"Demo Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Database/Demo Notes"},{"name":"iconClass","value":"bx bx-package","type":"label"}]}]},{"id":"_help_Gzjqa934BdH4","title":"Configuration (config.ini or environment variables)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or e"},{"name":"iconClass","value":"bx bx-cog","type":"label"}],"children":[{"id":"_help_c5xB8m4g2IY6","title":"Trilium instance","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Trilium instance"},{"name":"iconClass","value":"bx bx-windows","type":"label"}]},{"id":"_help_LWtBjFej3wX3","title":"Cross-Origin Resource Sharing (CORS)","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Configuration (config.ini or environment variables)/Cross-Origin Resource Sharing "},{"name":"iconClass","value":"bx bx-lock","type":"label"}]}]},{"id":"_help_ivYnonVFBxbQ","title":"Bulk Actions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Bulk Actions"},{"name":"iconClass","value":"bx bx-list-plus","type":"label"}]},{"id":"_help_4FahAwuGTAwC","title":"Note source","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note source"},{"name":"iconClass","value":"bx bx-code","type":"label"}]},{"id":"_help_1YeN2MzFUluU","title":"Technologies used","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used"},{"name":"iconClass","value":"bx bx-pyramid","type":"label"}],"children":[{"id":"_help_MI26XDLSAlCD","title":"CKEditor","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/CKEditor"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_N4IDkixaDG9C","title":"MindElixir","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/MindElixir"},{"name":"iconClass","value":"bx bx-sitemap","type":"label"}]},{"id":"_help_H0mM1lTxF9JI","title":"Excalidraw","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Excalidraw"},{"name":"iconClass","value":"bx bx-pen","type":"label"}]},{"id":"_help_MQHyy2dIFgxS","title":"Leaflet","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Technologies used/Leaflet"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]}]},{"id":"_help_m1lbrzyKDaRB","title":"Note ID","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Note ID"},{"name":"iconClass","value":"bx bx-hash","type":"label"}]},{"id":"_help_0vTSyvhPTAOz","title":"Internal API","type":"book","attributes":[{"name":"iconClass","value":"bx bxs-component","type":"label"}],"children":[{"id":"_help_z8O2VG4ZZJD7","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/rest-api/internal/"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_2mUhVmZK8RF3","title":"Hidden Notes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Hidden Notes"},{"name":"iconClass","value":"bx bx-hide","type":"label"}]},{"id":"_help_uYF7pmepw27K","title":"Metrics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Metrics"},{"name":"iconClass","value":"bx bxs-data","type":"label"}],"children":[{"id":"_help_bOP3TB56fL1V","title":"grafana-dashboard.json","type":"doc","attributes":[{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_64ZTlUPgEPtW","title":"Safe mode","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Safe mode"},{"name":"iconClass","value":"bx bxs-virus-block","type":"label"}]},{"id":"_help_HAIOFBoYIIdO","title":"Nightly release","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Nightly release"},{"name":"iconClass","value":"bx bx-moon","type":"label"}]},{"id":"_help_ZmT9ln8XJX2o","title":"Read-only database","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Read-only database"},{"name":"iconClass","value":"bx bx-book-reader","type":"label"}]}]},{"id":"_help_GBBMSlVSOIGP","title":"AI","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI"},{"name":"iconClass","value":"bx bx-bot","type":"label"}],"children":[{"id":"_help_WkM7gsEUyCXs","title":"Providers","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Providers"},{"name":"iconClass","value":"bx bx-select-multiple","type":"label"}],"children":[{"id":"_help_7EdTxPADv95W","title":"Ollama","type":"book","attributes":[{"name":"iconClass","value":"bx bx-message-dots","type":"label"}],"children":[{"id":"_help_vvUCN7FDkq7G","title":"Installing Ollama","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Providers/Ollama/Installing Ollama"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_ZavFigBX9AwP","title":"OpenAI","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Providers/OpenAI"},{"name":"iconClass","value":"bx bx-message-dots","type":"label"}]},{"id":"_help_e0lkirXEiSNc","title":"Anthropic","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Providers/Anthropic"},{"name":"iconClass","value":"bx bx-message-dots","type":"label"}]}]}]},{"id":"_help_CdNpE2pqjmI6","title":"Scripting","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting"},{"name":"iconClass","value":"bx bxs-file-js","type":"label"}],"children":[{"id":"_help_yIhgI5H7A2Sm","title":"Frontend Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics"},{"name":"iconClass","value":"bx bx-window","type":"label"}],"children":[{"id":"_help_MgibgPcfeuGz","title":"Custom Widgets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets"},{"name":"iconClass","value":"bx bxs-widget","type":"label"}],"children":[{"id":"_help_YNxAqkI5Kg1M","title":"Word count widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_SynTBQiBsdYJ","title":"Widget Basics","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Widget Basics"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_M8IppdwVHSjG","title":"Right pane widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Right pane widget"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_VqGQnnPGnqAU","title":"CSS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/CSS"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_es8OU2GuguFU","title":"Examples","type":"book","attributes":[{"name":"iconClass","value":"bx bx-code-alt","type":"label"}],"children":[{"id":"_help_TjLYAo3JMO8X","title":"\"New Task\" launcher button","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Examples/New Task launcher button"},{"name":"iconClass","value":"bx bx-task","type":"label"}]},{"id":"_help_7kZPMD0uFwkH","title":"Downloading responses from Google Forms","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Examples/Downloading responses from Goo"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_DL92EjAaXT26","title":"Using promoted attributes to configure scripts","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Frontend Basics/Examples/Using promoted attributes to c"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}]},{"id":"_help_SPirpZypehBG","title":"Backend scripts","type":"book","attributes":[{"name":"iconClass","value":"bx bx-server","type":"label"}],"children":[{"id":"_help_fZ2IGYFXjkEy","title":"Server-side imports","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Backend scripts/Server-side imports"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_GPERMystNGTB","title":"Events","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Backend scripts/Events"},{"name":"iconClass","value":"bx bx-rss","type":"label"}]}]},{"id":"_help_GLks18SNjxmC","title":"Script API","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Script API"},{"name":"iconClass","value":"bx bx-code-curly","type":"label"}],"children":[{"id":"_help_Q2z6av6JZVWm","title":"Frontend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/script-api/frontend"},{"name":"iconClass","value":"bx bx-folder","type":"label"}],"enforceAttributes":true,"children":[{"id":"_help_habiZ3HU8Kw8","title":"FNote","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/script-api/frontend/interfaces/FNote.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_MEtfsqa5VwNi","title":"Backend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://docs.triliumnotes.org/script-api/backend"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"enforceAttributes":true}]},{"id":"_help_vElnKeDNPSVl","title":"Logging","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Logging"},{"name":"iconClass","value":"bx bx-terminal","type":"label"}]}]},{"id":"_help_Fm0j45KqyHpU","title":"Miscellaneous","type":"book","attributes":[{"name":"iconClass","value":"bx bx-info-circle","type":"label"}],"children":[{"id":"_help_WFbFXrgnDyyU","title":"Privacy Policy","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Miscellaneous/Privacy Policy"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_NcsmUYZRWEW4","title":"Patterns of personal knowledge","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Miscellaneous/Patterns of personal knowledge"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]}] \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Configuration (config.ini or e.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Configuration (config.ini or e.html index 46523a9cee..de18d335b0 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Configuration (config.ini or e.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Configuration (config.ini or e.html @@ -288,6 +288,34 @@

Logging Section

+

ExternalBlobStorage Section

+ + + + + + + + + + + + + + + + + + + + + + + +
Environment VariableTypeDefaultDescription
TRILIUM_EXTERNAL_BLOB_STORAGE_ENABLED + booleanfalseEnable external blob storage for large attachments
TRILIUM_EXTERNAL_BLOB_STORAGE_THRESHOLD + integer102400Size threshold in bytes (100KB default) for external storage
+

Alternative Environment Variables

The following alternative environment variable names are also supported and work identically to their longer counterparts:

diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Database.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Database.html index 6bd4231955..2dd6b9b8f6 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Database.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Database.html @@ -2,6 +2,10 @@ which contains all notes, tree structure, metadata, and most of the configuration. The database file is named document.db and is stored in the application's default Data directory.

+

By default, all note and attachment content is stored within the database. + However, when external blob storage is enabled, + large notes and attachments are stored separately in the external-blobs directory + for better performance and backup flexibility.

Demo Notes

When first starting Trilium, it will provide a set of notes to showcase various features of the application.

diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/External Blob Storage.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/External Blob Storage.html new file mode 100644 index 0000000000..6c7614d0a2 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/External Blob Storage.html @@ -0,0 +1,59 @@ +

External blob storage is an optional feature that stores large attachments + as separate files on the filesystem instead of embedding them in the SQLite + database. This feature is disabled by default to maintain backward compatibility + and simplicity for most users.

+

When to Enable External Blob Storage

+

Benefits of enabling external storage:

+
    +
  • Performance: Large attachments don't bloat the database, + which can improve query performance
  • +
  • Backup flexibility: Large files can be backed up separately + from the database using different strategies or schedules
  • +
  • Storage efficiency: Easier to manage and migrate large + files independently
  • +
+

When to keep it disabled (default):

+
    +
  • Simplicity: All data in one database file makes backup + and migration straightforward
  • +
  • Small attachments: If your attachments are mostly small, + database storage is more efficient
  • +
  • Portability: Single-file database is easier to copy and + move between systems
  • +
+

How It Works

+

When external blob storage is enabled and a note or attachment exceeds + the configured threshold size the content is saved to the file system in + the external-blobs directory within your data directory. + The files are organized in a partitioned directory structure to prevent + excessive files in a single directory. The database stores a reference + to the external file and any additional metadata.

+

Attachments below the threshold, or when external storage is disabled, + continue to be stored in the database.

+

When you enable external blob storage, existing attachments in the database + remain there. Only new attachments that exceed the threshold will be stored + externally. Automatically migrating existing attachments is currently not + supported.

+

If you disable external blob storage after using it, existing external + blobs remain in the external-blobs directory and continue to + work. New attachments will be stored in the database regardless of size.

+

Configuration

+

External blob storage can be configured via config.ini or environment + variables. See the Configuration documentation + for all available options.

+

Threshold Size Recommendations

+

The default threshold of 100KB (102400 bytes) is based on SQLite's performance recommendations.

+

You can adjust the threshold based on your needs:

+
    +
  • Lower threshold (e.g., 50KB): More files stored externally, + smaller database
  • +
  • Higher threshold (e.g., 1MB): Fewer external files, larger + database but simpler backup
  • +
+

Important Backup Considerations

+

When external blob storage is enabled, you must backup both the database + and the external-blobs directory.

+

Without backing up the external-blobs directory, you would + lose large attachments if restoring from backup.

+

See the Backup documentation for detailed + backup strategies with external blob storage.

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Note source.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Note source.html index 2292a7cec9..a306420d76 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Note source.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Note source.html @@ -3,20 +3,20 @@

Understanding the source code of the different notes

on the Note Types.

For example:

    -
  • Text notes are +
  • Text notes are represented internally as HTML, using the CKEditor representation. Note that due to the custom plugins, some HTML elements are specific to Trilium only, for example the admonitions.
  • -
  • Code notes are +
  • Code notes are plain text and are represented internally as-is.
  • -
  • Geo Map notes +
  • Geo Map notes contain only minimal information (viewport, zoom) as a JSON.
  • -
  • Canvas notes +
  • Canvas notes are represented as JSON, with Trilium's own information alongside with  Excalidraw's internal JSON representation format.
  • -
  • Mind Map notes +
  • Mind Map notes are represented as JSON, with the internal format of MindElixir.
@@ -53,13 +53,11 @@

Modifying the source code

via the Note source functionality. 

To do so:

    -
  1. Change the note type from the real note type (e.g. Canvas, Geo Type) to +
  2. Change the note type from the real note type (e.g. Canvas, Geo Type) to Code (plain text) or the corresponding format such as JSON or HTML.
  3. -
  4. Confirm the warning about changing the note type.
  5. -
  6. The source code will appear, make the necessary modifications.
  7. -
  8. Change the note type back to the real note type.
  9. +
  10. Confirm the warning about changing the note type.
  11. +
  12. The source code will appear, make the necessary modifications.
  13. +
  14. Change the note type back to the real note type.