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 bd6d49aabe..da62e509d8 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-file","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-file","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","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Apache"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_l2VkvOwUNfZj","title":"TLS Configuration","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/TLS Configuration"},{"name":"iconClass","value":"bx bx-file","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-lock-alt","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_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-file","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-file","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-file","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":"Export as PDF","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Export as PDF"},{"name":"iconClass","value":"bx bxs-file-pdf","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":"book","attributes":[{"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-file","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-file","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_GTwFsgaA0lCt","title":"Collections","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections"},{"name":"iconClass","value":"bx bx-book","type":"label"}],"children":[{"id":"_help_xWbu3jpNWapp","title":"Calendar View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Calendar View"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_81SGnPGMk7Xc","title":"Geo Map View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Geo Map View"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]},{"id":"_help_8QqnMzx393bx","title":"Grid View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/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/Note Types/Collections/List View"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_2FvYrpmOXm29","title":"Table View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Table View"},{"name":"iconClass","value":"bx bx-table","type":"label"}]},{"id":"_help_CtBQqbwXDx1w","title":"Board View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Board View"},{"name":"iconClass","value":"bx bx-columns","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_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","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-file","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-file","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-file","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-file","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 bx-file","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-file","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-file","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 bx-file","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-file","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 bx-file","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-file","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-file","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 bx-file","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-file","type":"label"}],"children":[{"id":"_help_9qPsTWBorUhQ","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/etapi/docs"},{"name":"iconClass","value":"bx bx-file","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-file","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 bx-file","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-file","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-file","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-file","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 bxs-component","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-file","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-file","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-file","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 bx-folder","type":"label"}],"children":[{"id":"_help_z8O2VG4ZZJD7","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/api/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"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_LMAv4Uy3Wk6J","title":"AI","type":"book","attributes":[{"name":"iconClass","value":"bx bx-bot","type":"label"}],"children":[{"id":"_help_GBBMSlVSOIGP","title":"Introduction","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Introduction"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WkM7gsEUyCXs","title":"AI Provider Information","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_7EdTxPADv95W","title":"Ollama","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_vvUCN7FDkq7G","title":"Installing Ollama","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/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/AI Provider Information/OpenAI"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_e0lkirXEiSNc","title":"Anthropic","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Anthropic"},{"name":"iconClass","value":"bx bx-file","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-file","type":"label"}]},{"id":"_help_es8OU2GuguFU","title":"Examples","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_TjLYAo3JMO8X","title":"\"New Task\" launcher button","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/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/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/Examples/Using promoted attributes to c"},{"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/Events"},{"name":"iconClass","value":"bx bx-rss","type":"label"}]},{"id":"_help_MgibgPcfeuGz","title":"Custom Widgets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YNxAqkI5Kg1M","title":"Word count widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/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/Custom Widgets/Widget Basics"},{"name":"iconClass","value":"bx bx-file","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-file","type":"label"}],"children":[{"id":"_help_Q2z6av6JZVWm","title":"Frontend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Frontend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_habiZ3HU8Kw8","title":"FNote","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/classes/Frontend_Script_API.FNote.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_MEtfsqa5VwNi","title":"Backend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Backend_Script_API.Api.html"},{"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-file","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-file","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","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/2. Reverse proxy/Apache"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_l2VkvOwUNfZj","title":"TLS Configuration","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Server Installation/TLS Configuration"},{"name":"iconClass","value":"bx bx-file","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-lock-alt","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_YXZ2zPDYrWbn","title":"Security","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_im3dphtmpQ46","title":"Protected Notes and Encryption","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/Security/Protected Notes and Encryption"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_U9HWXPbFIRW3","title":"Server Installation","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_2L6zxH5z1QMw","title":"1. Installing the server","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_baqzSqQefEpE","title":"Manually","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Manually"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_Ycyax5BCWgUb","title":"Multiple server instances","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Multiple server instances"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_6YVZgQtfkxkS","title":"On NixOS","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/On NixOS"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_Q5jqLlwsnIBb","title":"Packaged version for Linux","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Packaged version for Linux"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_FNVzcT2FwEjq","title":"Using Docker","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Using Docker"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_9ErAobCLD1jl","title":"Using Kubernetes","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Using Kubernetes"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_pIaUHjns1S3p","title":"2. Reverse proxy","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_Eu2zkbyE2QPK","title":"Apache","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/2. Reverse proxy/Apache"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_vFhVZ4ZRNxLY","title":"Nginx","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/2. Reverse proxy/Nginx"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_CpWc4lepON8y","title":"Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/Authentication"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_iYzJELZlnPVk","title":"Multi-Factor Authentication","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/Multi-Factor Authentication"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_1G2n2zCStCir","title":"TLS Configuration","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Installation & Setup/1_Server Installation/TLS Configuration"},{"name":"iconClass","value":"bx bx-file","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-file","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-file","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-file","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":"Export as PDF","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Basic Concepts and Features/Notes/Export as PDF"},{"name":"iconClass","value":"bx bxs-file-pdf","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":"book","attributes":[{"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-file","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-file","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_GTwFsgaA0lCt","title":"Collections","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections"},{"name":"iconClass","value":"bx bx-book","type":"label"}],"children":[{"id":"_help_xWbu3jpNWapp","title":"Calendar View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Calendar View"},{"name":"iconClass","value":"bx bx-calendar","type":"label"}]},{"id":"_help_81SGnPGMk7Xc","title":"Geo Map View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Geo Map View"},{"name":"iconClass","value":"bx bx-map-alt","type":"label"}]},{"id":"_help_8QqnMzx393bx","title":"Grid View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/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/Note Types/Collections/List View"},{"name":"iconClass","value":"bx bx-list-ul","type":"label"}]},{"id":"_help_2FvYrpmOXm29","title":"Table View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Table View"},{"name":"iconClass","value":"bx bx-table","type":"label"}]},{"id":"_help_CtBQqbwXDx1w","title":"Board View","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Note Types/Collections/Board View"},{"name":"iconClass","value":"bx bx-columns","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_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","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-file","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-file","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-file","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-file","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 bx-file","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-file","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-file","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 bx-file","type":"label"}]}]},{"id":"_help_tC7s2alapj8V","title":"Advanced Usage","type":"book","attributes":[{"name":"iconClass","value":"bx bx-rocket","type":"label"}],"children":[{"id":"_help_ZuNbtBacBZ6Z","title":"Search","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_ey9TMFyD8SHR","title":"Advanced-Search-Expressions","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Search/Advanced-Search-Expressions"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_6SpIgsGqzwNI","title":"README","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Search/README"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_UUBStSxWzjgA","title":"Saved-Searches","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Search/Saved-Searches"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_yAFfA1SAYlr7","title":"Search-Examples-and-Use-Cases","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Search/Search-Examples-and-Use-Cases"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WcwMZ2tDZmXK","title":"Search-Fundamentals","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Search/Search-Fundamentals"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_ttFz520bcNLf","title":"Technical-Search-Details","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Advanced Usage/Search/Technical-Search-Details"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"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-file","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 bx-file","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-file","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-file","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 bx-file","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-file","type":"label"}],"children":[{"id":"_help_9qPsTWBorUhQ","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/etapi/docs"},{"name":"iconClass","value":"bx bx-file","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-file","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 bx-file","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-file","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-file","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-file","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 bxs-component","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-file","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-file","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-file","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 bx-folder","type":"label"}],"children":[{"id":"_help_z8O2VG4ZZJD7","title":"API Reference","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"/api/docs"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"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_LMAv4Uy3Wk6J","title":"AI","type":"book","attributes":[{"name":"iconClass","value":"bx bx-bot","type":"label"}],"children":[{"id":"_help_GBBMSlVSOIGP","title":"Introduction","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/Introduction"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_WkM7gsEUyCXs","title":"AI Provider Information","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_7EdTxPADv95W","title":"Ollama","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_vvUCN7FDkq7G","title":"Installing Ollama","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/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/AI Provider Information/OpenAI"},{"name":"iconClass","value":"bx bx-file","type":"label"}]},{"id":"_help_e0lkirXEiSNc","title":"Anthropic","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/AI/AI Provider Information/Anthropic"},{"name":"iconClass","value":"bx bx-file","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-file","type":"label"}]},{"id":"_help_es8OU2GuguFU","title":"Examples","type":"book","attributes":[{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_TjLYAo3JMO8X","title":"\"New Task\" launcher button","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/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/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/Examples/Using promoted attributes to c"},{"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/Events"},{"name":"iconClass","value":"bx bx-rss","type":"label"}]},{"id":"_help_MgibgPcfeuGz","title":"Custom Widgets","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/Custom Widgets"},{"name":"iconClass","value":"bx bx-file","type":"label"}],"children":[{"id":"_help_YNxAqkI5Kg1M","title":"Word count widget","type":"doc","attributes":[{"type":"label","name":"docName","value":"User Guide/User Guide/Scripting/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/Custom Widgets/Widget Basics"},{"name":"iconClass","value":"bx bx-file","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-file","type":"label"}],"children":[{"id":"_help_Q2z6av6JZVWm","title":"Frontend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Frontend_Script_API.Api.html"},{"name":"iconClass","value":"bx bx-folder","type":"label"}],"children":[{"id":"_help_habiZ3HU8Kw8","title":"FNote","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/classes/Frontend_Script_API.FNote.html"},{"name":"iconClass","value":"bx bx-file","type":"label"}]}]},{"id":"_help_MEtfsqa5VwNi","title":"Backend API","type":"webView","attributes":[{"type":"label","name":"webViewSrc","value":"https://triliumnext.github.io/Notes/Script%20API/interfaces/Backend_Script_API.Api.html"},{"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/Search/Advanced-Search-Expressions.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Advanced-Search-Expressions.html new file mode 100644 index 0000000000..a96ab75e1a --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Advanced-Search-Expressions.html @@ -0,0 +1,145 @@ +

Advanced Search Expressions

+

This guide covers complex search expressions that combine multiple criteria, + use advanced operators, and leverage Trilium's relationship system for + sophisticated queries.

+

Complex Query Construction

+

Boolean Logic with Parentheses

+

Use parentheses to group expressions and control evaluation order:

(#book OR #article) AND #author=Tolkien
+

Finds notes that are either books or articles, written by Tolkien.

#project AND (#status=active OR #status=pending)
+

Finds active or pending projects.

meeting AND (#priority=high OR #urgent) AND note.dateCreated >= TODAY-7
+

Finds recent high-priority or urgent meetings.

+

Negation Patterns

+

Use NOT or the not() function to exclude certain + criteria:

#book AND not(#genre=fiction)
+

Finds non-fiction books.

project AND not(note.isArchived=true)
+

Finds non-archived notes containing "project".

#!completed
+

Short syntax for notes without the "completed" label.

+

Mixed Search Types

+

Combine full-text, attribute, and property searches:

development #category=work note.type=text note.dateModified >= TODAY-30
+

Finds text notes about development, categorized as work, modified in the + last 30 days.

+

Advanced Attribute Searches

+

Fuzzy Attribute Matching

+

When fuzzy attribute search is enabled, you can use partial matches:

#lang
+

Matches labels like "language", "languages", "programming-lang", etc.

#category=prog
+

Matches categories like "programming", "progress", "program", etc.

+

Multiple Attribute Conditions

#book #author=Tolkien #publicationYear>=1950 #publicationYear<1960
+

Finds Tolkien's books published in the 1950s.

#task #priority=high #status!=completed
+

Finds high-priority incomplete tasks.

+

Complex Label Value Patterns

+

Use various operators for sophisticated label matching:

#isbn %= '978-[0-9-]+' 
+

Finds notes with ISBN labels matching the pattern (regex).

#email *=* @company.com
+

Finds notes with email labels containing "@company.com".

#version >= 2.0
+

Finds notes with version labels of 2.0 or higher (numeric comparison).

+

Relationship Traversal

+

Basic Relation Queries

~author.title *=* Tolkien
+

Finds notes with an "author" relation to notes containing "Tolkien" in + the title.

~project.labels.status = active
+

Finds notes related to projects with active status.

+

Multi-Level Relationships

~author.relations.publisher.title = "Penguin Books"
+

Finds notes authored by someone published by Penguin Books.

~project.children.title *=* documentation
+

Finds notes related to projects that have child notes about documentation.

+

Relationship Direction

note.children.title = "Chapter 1"
+

Finds parent notes that have a child titled "Chapter 1".

note.parents.labels.category = book
+

Finds notes whose parents are categorized as books.

note.ancestors.title = "Literature"
+

Finds notes with "Literature" anywhere in their ancestor chain.

+

Property-Based Searches

+

Note Metadata Queries

note.type=code note.mime=text/javascript note.dateCreated >= MONTH
+

Finds JavaScript code notes created this month.

note.isProtected=true note.contentSize > 1000
+

Finds large protected notes.

note.childrenCount >= 10 note.type=text
+

Finds text notes with many children.

+

Advanced Property Combinations

note.parentCount > 1 #template
+

Finds template notes that are cloned in multiple places.

note.attributeCount > 5 note.type=text note.contentSize < 500
+

Finds small text notes with many attributes (heavily tagged short notes).

note.revisionCount > 10 note.dateModified >= TODAY-7
+

Finds frequently edited notes modified recently.

+

Date and Time Expressions

+

Relative Date Calculations

#dueDate <= TODAY+7 #dueDate >= TODAY
+

Finds tasks due in the next week.

note.dateCreated >= MONTH-2 note.dateCreated < MONTH
+

Finds notes created in the past two months.

#eventDate = YEAR note.dateCreated >= YEAR-1
+

Finds events scheduled for this year that were planned last year.

+

Complex Date Logic

(#startDate <= TODAY AND #endDate >= TODAY) OR #status=ongoing
+

Finds current events or ongoing items.

#reminderDate <= NOW+3600 #reminderDate > NOW
+

Finds reminders due in the next hour (using seconds offset).

+

Fuzzy Search Techniques

+

Fuzzy Exact Matching

#title ~= managment
+

Finds notes with titles like "management" even with typos.

~category.title ~= progaming
+

Finds notes related to categories like "programming" with misspellings.

+

Fuzzy Contains Matching

note.content ~* algoritm
+

Finds notes containing words like "algorithm" with spelling variations.

#description ~* recieve
+

Finds notes with descriptions containing "receive" despite the common + misspelling.

+

Progressive Fuzzy Strategy

+

By default, Trilium uses exact matching first, then fuzzy as fallback:

development project
+

First finds exact matches for "development" and "project", then adds fuzzy + matches if needed.

+

To force fuzzy behavior:

#title ~= development #category ~= projet
+

Ordering and Limiting

+

Multiple Sort Criteria

#book orderBy #publicationYear desc, note.title asc limit 20
+

Orders books by publication year (newest first), then by title alphabetically, + limited to 20 results.

#task orderBy #priority desc, #dueDate asc
+

Orders tasks by priority (high first), then by due date (earliest first).

+

Dynamic Ordering

#meeting note.dateCreated >= TODAY-30 orderBy note.dateModified desc
+

Finds recent meetings ordered by last modification.

#project #status=active orderBy note.childrenCount desc limit 10
+

Finds the 10 most complex active projects (by number of sub-notes).

+

Performance Optimization Patterns

+

Efficient Query Structure

+

Start with the most selective criteria:

#book #author=Tolkien note.dateCreated >= 1950-01-01
+

Better than:

note.dateCreated >= 1950-01-01 #book #author=Tolkien
+

Fast Search for Large Datasets

#category=project #status=active
+

With fast search enabled, this searches only attributes, not content.

+

Limiting Expensive Operations

note.content *=* "complex search term" limit 50
+

Limits content search to prevent performance issues.

+

Error Handling and Debugging

+

Syntax Validation

+

Invalid syntax produces helpful error messages:

#book AND OR #author=Tolkien
+

Error: "Mixed usage of AND/OR - always use parentheses to group AND/OR + expressions."

+

Debug Mode

+

Enable debug mode to see how queries are parsed:

#book #author=Tolkien
+

With debug enabled, shows the internal expression tree structure.

+

Common Pitfalls

+ +

Expression Shortcuts

+

Label Shortcuts

+

Full syntax:

note.labels.category = book
+

Shortcut:

#category = book
+

Relation Shortcuts

+

Full syntax:

note.relations.author.title *=* Tolkien
+

Shortcut:

~author.title *=* Tolkien
+

Property Shortcuts

+

Some properties have convenient shortcuts:

note.text *=* content
+

Searches both title and content for "content".

+

Real-World Complex Examples

+

Project Management

(#project OR #task) AND #status!=completed AND 
+(#priority=high OR #dueDate <= TODAY+7) AND 
+not(note.isArchived=true) 
+orderBy #priority desc, #dueDate asc
+

Research Organization

(#paper OR #article OR #book) AND 
+~author.title *=* smith AND 
+#topic *=* "machine learning" AND 
+note.dateCreated >= YEAR-2 
+orderBy #citationCount desc limit 25
+

Content Management

note.type=text AND note.contentSize > 5000 AND 
+#category=documentation AND note.childrenCount >= 3 AND 
+note.dateModified >= MONTH-1 
+orderBy note.dateModified desc
+

Knowledge Base Maintenance

note.attributeCount = 0 AND note.childrenCount = 0 AND 
+note.parentCount = 1 AND note.contentSize < 100 AND 
+note.dateModified < TODAY-90
+

Finds potential cleanup candidates: small, untagged, isolated notes not + modified in 90 days.

+

Next Steps

+ \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/README.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/README.html new file mode 100644 index 0000000000..ebdb0ee2e8 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/README.html @@ -0,0 +1,158 @@ +

Trilium Search Documentation

+

Welcome to the comprehensive guide for Trilium's powerful search capabilities. + This documentation covers everything from basic text searches to advanced + query expressions and performance optimization.

+

Quick Start

+

New to Trilium search? Start here:

+ +

Documentation Sections

+

Core Search Features

+ +

Practical Applications

+ +

Technical Reference

+ +

Key Search Capabilities

+

Full-Text Search

+ +

Attribute-Based Search

+ +

Property Search

+ +

Advanced Features

+ +

Search Operators Quick Reference

+

Text Operators

+ +

Numeric Operators

+ +

Boolean Operators

+ +

Special Syntax

+ +

Common Search Patterns

+

Simple Searches

hello world          # Find notes containing both words
+"project management" # Find exact phrase
+#task               # Find notes with "task" label
+~author             # Find notes with "author" relation
+

Attribute Searches

#book #author=Tolkien           # Books by Tolkien
+#task #priority=high #status!=completed  # High-priority incomplete tasks
+~project.title *=* alpha        # Notes related to projects with "alpha" in title
+

Date-Based Searches

note.dateCreated >= TODAY-7     # Notes created in last week
+#dueDate <= TODAY+30            # Items due in next 30 days
+#eventDate = YEAR               # Events scheduled for this year
+

Complex Queries

(#book OR #article) AND #topic=programming AND note.dateModified >= MONTH
+#project AND (#status=active OR #status=pending) AND not(note.isArchived=true)
+

Getting Started Checklist

+
    +
  1. Learn Basic Syntax - Start with simple text and tag searches
  2. +
  3. Understand Operators - Master the core operators (=, *=*, + etc.)
  4. +
  5. Practice Attributes - Use # for labels and ~ for + relations
  6. +
  7. Try Boolean Logic - Combine searches with AND/OR/NOT
  8. +
  9. Explore Properties - Use note. prefix for metadata + searches
  10. +
  11. Create Saved Searches - Turn useful queries into dynamic + collections
  12. +
  13. Optimize Performance - Learn about fast search and limits
  14. +
+

Performance Tips

+ +

Need Help?

+ +

Search Workflow Integration

+

Trilium's search integrates seamlessly with your note-taking workflow:

+ +

Start with the fundamentals and gradually explore advanced features as + your needs grow. Trilium's search system is designed to scale from simple + text queries to sophisticated knowledge management systems.

+

Happy searching! 🔍

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Saved-Searches.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Saved-Searches.html new file mode 100644 index 0000000000..f25effb5b1 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Saved-Searches.html @@ -0,0 +1,293 @@ +

Saved Searches

+

Saved searches in Trilium allow you to create dynamic collections of notes + that automatically update based on search criteria. They appear as special + notes in your tree and provide a powerful way to organize and access related + content.

+

Understanding Saved Searches

+

A saved search is a special note type that:

+ +

Creating Saved Searches

+

From Search Dialog

+
    +
  1. Open the search dialog (Ctrl+S or search icon)
  2. +
  3. Configure your search criteria and options
  4. +
  5. Click "Save to note" button
  6. +
  7. Choose a name and location for the saved search
  8. +
+

Manual Creation

+
    +
  1. Create a new note and set its type to "Saved Search"
  2. +
  3. Configure the search using labels: + +
  4. +
+

Using Search Scripts

+

For complex logic, create a JavaScript note and link it:

+ +

Basic Saved Search Examples

+

Simple Text Search

#searchString=project management
+

Finds all notes containing "project management".

+

Tag-Based Collection

#searchString=#book #author=Tolkien
+#orderBy=publicationYear
+#orderDirection=desc
+

Creates a collection of Tolkien's books ordered by publication year.

+

Task Dashboard

#searchString=#task #status!=completed #assignee=me
+#orderBy=priority
+#orderDirection=desc
+#limit=20
+

Shows your top 20 incomplete tasks by priority.

+

Recent Activity

#searchString=note.dateModified >= TODAY-7
+#orderBy=dateModified
+#orderDirection=desc
+#limit=50
+

Shows the 50 most recently modified notes from the last week.

+

Advanced Saved Search Patterns

+

Dynamic Date-Based Collections

+

This Week's Content

#searchString=note.dateCreated >= TODAY-7 note.dateCreated < TODAY
+#orderBy=dateCreated
+#orderDirection=desc
+

Monthly Review Collection

#searchString=#reviewed=false note.dateCreated >= MONTH note.dateCreated < MONTH+1
+#orderBy=dateCreated
+

Upcoming Deadlines

#searchString=#dueDate >= TODAY #dueDate <= TODAY+14 #status!=completed
+#orderBy=dueDate
+#orderDirection=asc
+

Project-Specific Collections

+

Project Dashboard

#searchString=#project=alpha (#task OR #milestone OR #document)
+#orderBy=priority
+#orderDirection=desc
+

Project Health Monitor

#searchString=#project=alpha #status=blocked OR (#dueDate < TODAY #status!=completed)
+#orderBy=dueDate
+#orderDirection=asc
+

Content Type Collections

+

Documentation Hub

#searchString=(#documentation OR #guide OR #manual) #product=api
+#orderBy=dateModified
+#orderDirection=desc
+

Learning Path

#searchString=#course #level=beginner #topic=programming
+#orderBy=difficulty
+#orderDirection=asc
+

Search Script Examples

+

For complex logic that can't be expressed in search strings, use JavaScript:

+

Custom Business Logic

// Find notes that need attention based on complex criteria
+const api = require('api');
+
+const cutoffDate = new Date();
+cutoffDate.setDate(cutoffDate.getDate() - 30);
+
+const results = [];
+
+// Find high-priority tasks overdue by more than a week
+const overdueTasks = api.searchForNotes(`
+    #task #priority=high #dueDate < TODAY-7 #status!=completed
+`);
+
+// Find projects with no recent activity
+const staleProjets = api.searchForNotes(`
+    #project #status=active note.dateModified < TODAY-30
+`);
+
+// Find notes with many attributes but no content
+const overlabeledNotes = api.searchForNotes(`
+    note.attributeCount > 5 note.contentSize < 100
+`);
+
+return [...overdueTasks, ...staleProjects, ...overlabeledNotes]
+    .map(note => note.noteId);
+

Dynamic Tag-Based Grouping

// Group notes by quarter based on creation date
+const api = require('api');
+
+const currentYear = new Date().getFullYear();
+const results = [];
+
+for (let quarter = 1; quarter <= 4; quarter++) {
+    const startMonth = (quarter - 1) * 3 + 1;
+    const endMonth = quarter * 3;
+    
+    const quarterNotes = api.searchForNotes(`
+        note.dateCreated >= "${currentYear}-${String(startMonth).padStart(2, '0')}-01"
+        note.dateCreated < "${currentYear}-${String(endMonth + 1).padStart(2, '0')}-01"
+        #project
+    `);
+    
+    results.push(...quarterNotes.map(note => note.noteId));
+}
+
+return results;
+

Conditional Search Logic

// Smart dashboard that changes based on day of week
+const api = require('api');
+
+const today = new Date();
+const dayOfWeek = today.getDay(); // 0 = Sunday, 1 = Monday, etc.
+
+let searchQuery;
+
+if (dayOfWeek === 1) { // Monday - weekly planning
+    searchQuery = '#task #status=planned #week=' + getWeekNumber(today);
+} else if (dayOfWeek === 5) { // Friday - weekly review
+    searchQuery = '#task #completed=true #week=' + getWeekNumber(today);
+} else { // Regular days - focus on today's work
+    searchQuery = '#task #dueDate=TODAY #status!=completed';
+}
+
+const notes = api.searchForNotes(searchQuery);
+return notes.map(note => note.noteId);
+
+function getWeekNumber(date) {
+    const firstDay = new Date(date.getFullYear(), 0, 1);
+    const pastDays = Math.floor((date - firstDay) / 86400000);
+    return Math.ceil((pastDays + firstDay.getDay() + 1) / 7);
+}
+

Performance Optimization

+

Fast Search for Large Collections

+

For collections that don't need content search:

#searchString=#category=reference #type=article
+#fastSearch=true
+#limit=100
+

Efficient Ordering

+

Use indexed properties for better performance:

#orderBy=dateCreated
+#orderBy=title
+#orderBy=noteId
+

Avoid complex calculated orderings in large collections.

+

Result Limiting

+

Always set reasonable limits for large collections:

#limit=50
+

For very large result sets, consider breaking into multiple saved searches.

+

Saved Search Organization

+

Hierarchical Organization

+

Create a folder structure for saved searches:

📁 Searches
+├── 📁 Projects
+│   ├── 🔍 Active Projects
+│   ├── 🔍 Overdue Tasks  
+│   └── 🔍 Project Archive
+├── 📁 Content
+│   ├── 🔍 Recent Drafts
+│   ├── 🔍 Published Articles
+│   └── 🔍 Review Queue
+└── 📁 Maintenance
+    ├── 🔍 Untagged Notes
+    ├── 🔍 Cleanup Candidates
+    └── 🔍 Orphaned Notes
+

Search Naming Conventions

+

Use clear, descriptive names:

+ +

Search Labels

+

Tag saved searches for organization:

#searchType=dashboard
+#searchType=maintenance  
+#searchType=archive
+#frequency=daily
+#frequency=weekly
+

Dashboard Creation

+

Personal Dashboard

+

Combine multiple saved searches in a parent note:

📋 My Dashboard
+├── 🔍 Today's Tasks
+├── 🔍 Urgent Items
+├── 🔍 Recent Notes
+├── 🔍 Upcoming Deadlines
+└── 🔍 Weekly Review Items
+

Project Dashboard

📋 Project Alpha Dashboard  
+├── 🔍 Active Tasks
+├── 🔍 Blocked Items
+├── 🔍 Recent Updates
+├── 🔍 Milestones
+└── 🔍 Team Notes
+

Content Dashboard

📋 Content Management
+├── 🔍 Draft Articles
+├── 🔍 Review Queue
+├── 🔍 Published This Month
+├── 🔍 High-Engagement Posts
+└── 🔍 Content Ideas
+

Maintenance and Updates

+

Regular Review

+

Periodically review saved searches for:

+ +

Search Evolution

+

As your note-taking evolves, update searches:

+ +

Performance Monitoring

+

Watch for performance issues:

+ +

Troubleshooting

+

Common Issues

+

Empty Results

+ +

Performance Problems

+ +

Unexpected Results

+ +

Best Practices

+

Search Design

+ +

Performance

+ +

Organization

+ +

Next Steps

+ \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Search-Examples-and-Use-Cases.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Search-Examples-and-Use-Cases.html new file mode 100644 index 0000000000..dc6049eb94 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Search-Examples-and-Use-Cases.html @@ -0,0 +1,152 @@ +

Search Examples and Use Cases

+

This guide provides practical examples of how to use Trilium's search + capabilities for common organizational patterns and workflows.

+

Personal Knowledge Management

+

Research and Learning

+

Track your learning progress and find related materials:

#topic=javascript #status=learning
+

Find all JavaScript materials you're currently learning.

#course #completed=false note.dateCreated >= MONTH-1
+

Find courses started in the last month that aren't completed.

#book #topic *=* programming #rating >= 4
+

Find highly-rated programming books.

#paper ~author.title *=* "Andrew Ng" #field=machine-learning
+

Find machine learning papers by Andrew Ng.

+

Meeting and Event Management

+

Organize meetings, notes, and follow-ups:

#meeting note.dateCreated >= TODAY-7 #attendee *=* smith
+

Find this week's meetings with Smith.

#meeting #actionItems #status!=completed
+

Find meetings with outstanding action items.

#event #date >= TODAY #date <= TODAY+30
+

Find upcoming events in the next 30 days.

#meeting #project=alpha note.dateCreated >= MONTH
+

Find this month's meetings about project alpha.

+

Note Organization and Cleanup

+

Maintain and organize your note structure:

note.childrenCount = 0 note.parentCount = 1 note.contentSize < 50 note.dateModified < TODAY-180
+

Find small, isolated notes not modified in 6 months (cleanup candidates).

note.attributeCount = 0 note.type=text note.contentSize > 1000
+

Find large text notes without any labels (might need categorization).

#draft note.dateCreated < TODAY-30
+

Find old draft notes that might need attention.

note.parentCount > 3 note.type=text
+

Find notes that are heavily cloned (might indicate important content).

+

Project Management

+

Task Tracking

+

Manage tasks and project progress:

#task #priority=high #status!=completed #assignee=me
+

Find your high-priority incomplete tasks.

#task #dueDate <= TODAY+3 #dueDate >= TODAY #status!=completed
+

Find tasks due in the next 3 days.

#project=website #task #status=blocked
+

Find blocked tasks in the website project.

#task #estimatedHours > 0 #actualHours > 0 orderBy note.dateModified desc
+

Find tasks with time tracking data, sorted by recent updates.

+

Project Oversight

+

Monitor project health and progress:

#project #status=active note.children.labels.status = blocked
+

Find active projects with blocked tasks.

#project #startDate <= TODAY-90 #status!=completed
+

Find projects that started over 90 days ago but aren't completed.

#milestone #targetDate <= TODAY #status!=achieved
+

Find overdue milestones.

#project orderBy note.childrenCount desc limit 10
+

Find the 10 largest projects by number of sub-notes.

+

Resource Planning

+

Track resources and dependencies:

#resource #type=person #availability < 50
+

Find people with low availability.

#dependency #status=pending #project=mobile-app
+

Find pending dependencies for the mobile app project.

#budget #project #spent > #allocated
+

Find projects over budget.

+

Content Creation and Writing

+

Writing Projects

+

Manage articles, books, and documentation:

#article #status=draft #wordCount >= 1000
+

Find substantial draft articles.

#chapter #book=novel #status=outline
+

Find novel chapters still in outline stage.

#blog-post #published=false #topic=technology
+

Find unpublished technology blog posts.

#documentation #lastReviewed < TODAY-90 #product=api
+

Find API documentation not reviewed in 90 days.

+

Editorial Workflow

+

Track editing and publication status:

#article #editor=jane #status=review
+

Find articles assigned to Jane for review.

#manuscript #submissionDate >= TODAY-30 #status=pending
+

Find manuscripts submitted in the last 30 days still pending.

#publication #acceptanceDate >= YEAR #status=accepted
+

Find accepted publications this year.

+

Content Research

+

Organize research materials and sources:

#source #reliability >= 8 #topic *=* climate
+

Find reliable sources about climate topics.

#quote #author *=* Einstein #verified=true
+

Find verified Einstein quotes.

#citation #used=false #relevance=high
+

Find high-relevance citations not yet used.

+

Business and Professional Use

+

Client Management

+

Track client relationships and projects:

#client=acme #project #status=active
+

Find active projects for ACME client.

#meeting #client #date >= MONTH #followUp=required
+

Find client meetings this month requiring follow-up.

#contract #renewalDate <= TODAY+60 #renewalDate >= TODAY
+

Find contracts expiring in the next 60 days.

#invoice #status=unpaid #dueDate < TODAY
+

Find overdue unpaid invoices.

+

Process Documentation

+

Maintain procedures and workflows:

#procedure #department=engineering #lastUpdated < TODAY-365
+

Find engineering procedures not updated in a year.

#workflow #status=active #automation=possible
+

Find active workflows that could be automated.

#checklist #process=onboarding #role=developer
+

Find onboarding checklists for developers.

+

Compliance and Auditing

+

Track compliance requirements and audits:

#compliance #standard=sox #nextReview <= TODAY+30
+

Find SOX compliance items due for review soon.

#audit #finding #severity=high #status!=resolved
+

Find unresolved high-severity audit findings.

#policy #department=hr #effectiveDate >= YEAR
+

Find HR policies that became effective this year.

+

Academic and Educational Use

+

Course Management

+

Organize courses and educational content:

#course #semester=fall-2024 #assignment #dueDate >= TODAY
+

Find upcoming assignments for fall 2024 courses.

#lecture #course=physics #topic *=* quantum
+

Find physics lectures about quantum topics.

#student #grade < 70 #course=mathematics
+

Find students struggling in mathematics.

#syllabus #course #lastUpdated < TODAY-180
+

Find syllabi not updated in 6 months.

+

Research Management

+

Track research projects and publications:

#experiment #status=running #endDate <= TODAY+7
+

Find experiments ending in the next week.

#dataset #size > 1000000 #cleaned=true #public=false
+

Find large, cleaned, private datasets.

#hypothesis #tested=false #priority=high
+

Find high-priority untested hypotheses.

#collaboration #institution *=* stanford #status=active
+

Find active collaborations with Stanford.

+

Grant and Funding

+

Manage funding applications and requirements:

#grant #deadline <= TODAY+30 #deadline >= TODAY #status=in-progress
+

Find grant applications due in the next 30 days.

#funding #amount >= 100000 #status=awarded #startDate >= YEAR
+

Find large grants awarded this year.

#report #funding #dueDate <= TODAY+14 #status!=submitted
+

Find funding reports due in 2 weeks.

+

Technical Documentation

+

Code and Development

+

Track code-related notes and documentation:

#bug #severity=critical #status!=fixed #product=webapp
+

Find critical unfixed bugs in the web app.

#feature #version=2.0 #status=implemented #tested=false
+

Find version 2.0 features that are implemented but not tested.

#api #endpoint #deprecated=true #removalDate <= TODAY+90
+

Find deprecated API endpoints scheduled for removal soon.

#architecture #component=database #lastReviewed < TODAY-180
+

Find database architecture documentation not reviewed in 6 months.

+

System Administration

+

Manage infrastructure and operations:

#server #status=maintenance #scheduledDate >= TODAY #scheduledDate <= TODAY+7
+

Find servers scheduled for maintenance this week.

#backup #status=failed #date >= TODAY-7
+

Find backup failures in the last week.

#security #vulnerability #severity=high #patched=false
+

Find unpatched high-severity vulnerabilities.

#monitoring #alert #frequency > 10 #period=week
+

Find alerts triggering more than 10 times per week.

+

Data Analysis and Reporting

+

Performance Tracking

+

Monitor metrics and KPIs:

#metric #kpi=true #trend=declining #period=month
+

Find declining monthly KPIs.

#report #frequency=weekly #lastGenerated < TODAY-10
+

Find weekly reports that haven't been generated in 10 days.

#dashboard #stakeholder=executive #lastUpdated < TODAY-7
+

Find executive dashboards not updated this week.

+

Trend Analysis

+

Track patterns and changes over time:

#data #source=sales #period=quarter #analyzed=false
+

Find unanalyzed quarterly sales data.

#trend #direction=up #significance=high #period=month
+

Find significant positive monthly trends.

#forecast #accuracy < 80 #model=linear #period=quarter
+

Find inaccurate quarterly linear forecasts.

+

Search Strategy Tips

+

Building Effective Queries

+
    +
  1. Start Specific: Begin with the most selective criteria
  2. +
  3. Add Gradually: Build complexity incrementally
  4. +
  5. Test Components: Verify each part of complex queries
  6. +
  7. Use Shortcuts: Leverage # and ~ shortcuts + for efficiency
  8. +
+

Performance Optimization

+
    +
  1. Use Fast Search: For large databases, enable fast search + when content isn't needed
  2. +
  3. Limit Results: Add limits to prevent overwhelming result + sets
  4. +
  5. Order Strategically: Put the most useful results first
  6. +
  7. Cache Common Queries: Save frequently used searches
  8. +
+

Maintenance Patterns

+

Regular queries for note maintenance:

# Weekly cleanup check
+note.attributeCount = 0 note.type=text note.contentSize < 100 note.dateModified < TODAY-30
+
+# Monthly project review  
+#project #status=active note.dateModified < TODAY-30
+
+# Quarterly archive review
+note.isArchived=false note.dateModified < TODAY-90 note.childrenCount = 0
+

Next Steps

+ \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Search-Fundamentals.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Search-Fundamentals.html new file mode 100644 index 0000000000..b4ed663af5 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Search-Fundamentals.html @@ -0,0 +1,153 @@ +

Search Fundamentals

+

Trilium's search system is a powerful tool for finding and organizing + notes. It supports multiple search modes, from simple text queries to complex + expressions using attributes, relationships, and note properties.

+

Search Types Overview

+

Trilium provides three main search approaches:

+
    +
  1. Full-text Search - Searches within note titles and content
  2. +
  3. Attribute Search - Searches based on labels and relations + attached to notes
  4. +
  5. Property Search - Searches based on note metadata (type, + creation date, etc.)
  6. +
+

These can be combined in powerful ways to create precise queries.

+

Basic Search Syntax

+

Simple Text Search

hello world
+

Finds notes containing both "hello" and "world" anywhere in the title + or content.

+

Quoted Text Search

"hello world"
+

Finds notes containing the exact phrase "hello world".

+

Attribute Search

#tag
+

Finds notes with the label "tag".

#category=book
+

Finds notes with label "category" set to "book".

+

Relation Search

~author
+

Finds notes with a relation named "author".

~author.title=Tolkien
+

Finds notes with an "author" relation pointing to a note titled "Tolkien".

+

Search Operators

+

Text Operators

+ +

Numeric Operators

+ +

Boolean Operators

+ +

Search Context and Scope

+

Search Scope

+

By default, search covers:

+ +

Fast Search Mode

+

When enabled, fast search:

+ +

Archived Notes

+ +

Case Sensitivity and Normalization

+ +

Performance Considerations

+

Content Size Limits

+ +

Progressive Search Strategy

+
    +
  1. Exact Search Phase: Fast exact matching (handles 90%+ + of searches)
  2. +
  3. Fuzzy Search Phase: Activated when exact search returns + fewer than 5 high-quality results
  4. +
  5. Result Ordering: Exact matches always appear before fuzzy + matches
  6. +
+

Search Optimization Tips

+ +

Special Characters and Escaping

+

Reserved Characters

+

These characters have special meaning in search queries:

+ +

Escaping Special Characters

+

Use backslash to search for literal special characters:

\#hashtag
+

Searches for the literal text "#hashtag" instead of a label.

+

Use quotes to include special characters in phrases:

"note.txt file"
+

Searches for the exact phrase including the dot.

+

Date and Time Values

+

Special Date Keywords

+ +

Date Arithmetic

#dateCreated >= TODAY-30
+

Finds notes created in the last 30 days.

#eventDate = YEAR+1
+

Finds notes with eventDate set to next year.

+

Search Results and Scoring

+

Result Ranking

+

Results are ordered by:

+
    +
  1. Relevance score (based on term frequency and position)
  2. +
  3. Note depth (closer to root ranks higher)
  4. +
  5. Alphabetical order for ties
  6. +
+

Progressive Search Behavior

+ +

Next Steps

+ \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Technical-Search-Details.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Technical-Search-Details.html new file mode 100644 index 0000000000..23c85a7236 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Search/Technical-Search-Details.html @@ -0,0 +1,486 @@ +

Technical Search Details

+

This guide provides technical information about Trilium's search implementation, + performance characteristics, and optimization strategies for power users + and administrators.

+

Search Architecture Overview

+

Three-Layer Search System

+

Trilium's search operates across three cache layers:

+
    +
  1. Becca (Backend Cache): Server-side entity cache containing + notes, attributes, and relationships
  2. +
  3. Froca (Frontend Cache): Client-side mirror providing + fast UI updates
  4. +
  5. Database Layer: SQLite database with FTS (Full-Text Search) + support
  6. +
+

Search Processing Pipeline

+
    +
  1. Lexical Analysis: Query parsing and tokenization
  2. +
  3. Expression Building: Converting tokens to executable + expressions
  4. +
  5. Progressive Execution: Exact search followed by optional + fuzzy search
  6. +
  7. Result Scoring: Relevance calculation and ranking
  8. +
  9. Result Presentation: Formatting and highlighting
  10. +
+

Query Processing Details

+

Lexical Analysis (Lex)

+

The lexer breaks down search queries into components:

// Input: 'project #status=active note.dateCreated >= TODAY-7'
+// Output:
+{
+  fulltextTokens: ['project'],
+  expressionTokens: ['#status', '=', 'active', 'note', '.', 'dateCreated', '>=', 'TODAY-7']
+}
+

Token Types

+ +

Expression Building (Parse)

+

Tokens are converted into executable expression trees:

// Expression tree for: #book AND #author=Tolkien
+AndExp([
+  AttributeExistsExp('label', 'book'),
+  LabelComparisonExp('label', 'author', equals('tolkien'))
+])
+

Expression Types

+ +

Progressive Search Strategy

+

Phase 1: Exact Search

// Fast exact matching
+const exactResults = performSearch(expression, searchContext, false);
+

Characteristics:

+ +

Phase 2: Fuzzy Fallback

// Activated when exact results < 5 high-quality matches
+if (highQualityResults.length < 5) {
+  const fuzzyResults = performSearch(expression, searchContext, true);
+  return mergeExactAndFuzzyResults(exactResults, fuzzyResults);
+}
+

Characteristics:

+ +

Performance Characteristics

+

Search Limits and Thresholds

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterValuePurpose
MAX_SEARCH_CONTENT_SIZE + 2MBDatabase-level content filtering
MIN_FUZZY_TOKEN_LENGTH + 3 charsMinimum length for fuzzy matching
MAX_EDIT_DISTANCE + 2 charsMaximum character changes for fuzzy
MAX_PHRASE_PROXIMITY + 10 wordsMaximum distance for phrase matching
RESULT_SUFFICIENCY_THRESHOLD + 5 resultsThreshold for fuzzy activation
ABSOLUTE_MAX_CONTENT_SIZE + 100MBHard limit to prevent system crash
ABSOLUTE_MAX_WORD_COUNT + 2M wordsHard limit for word processing
+ +

Performance Optimization

+

Database-Level Optimizations

-- Content size filtering at database level
+SELECT noteId, type, mime, content, isProtected
+FROM notes JOIN blobs USING (blobId)
+WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap') 
+  AND isDeleted = 0 
+  AND LENGTH(content) < 2097152  -- 2MB limit
+

Memory Management

+ +

Search Context Optimization

// Efficient search context configuration
+const searchContext = new SearchContext({
+  fastSearch: true,           // Skip content search
+  limit: 50,                 // Reasonable result limit
+  orderBy: 'dateCreated',    // Use indexed property
+  includeArchivedNotes: false // Reduce search space
+});
+

Fuzzy Search Implementation

+

Edit Distance Algorithm

+

Trilium uses an optimized Levenshtein distance calculation:

// Optimized single-array implementation
+function calculateOptimizedEditDistance(str1, str2, maxDistance) {
+  // Early termination checks
+  if (Math.abs(str1.length - str2.length) > maxDistance) {
+    return maxDistance + 1;
+  }
+  
+  // Single array optimization
+  let previousRow = Array.from({ length: str2.length + 1 }, (_, i) => i);
+  let currentRow = new Array(str2.length + 1);
+  
+  for (let i = 1; i <= str1.length; i++) {
+    currentRow[0] = i;
+    let minInRow = i;
+    
+    for (let j = 1; j <= str2.length; j++) {
+      const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
+      currentRow[j] = Math.min(
+        previousRow[j] + 1,        // deletion
+        currentRow[j - 1] + 1,     // insertion
+        previousRow[j - 1] + cost  // substitution
+      );
+      minInRow = Math.min(minInRow, currentRow[j]);
+    }
+    
+    // Early termination if row minimum exceeds threshold
+    if (minInRow > maxDistance) return maxDistance + 1;
+    
+    [previousRow, currentRow] = [currentRow, previousRow];
+  }
+  
+  return previousRow[str2.length];
+}
+

Phrase Proximity Matching

+

For multi-token fuzzy searches:

// Check if tokens appear within reasonable proximity
+function hasProximityMatch(tokenPositions, maxDistance = 10) {
+  // For 2 tokens, simple distance check
+  if (tokenPositions.length === 2) {
+    const [pos1, pos2] = tokenPositions;
+    return pos1.some(p1 => pos2.some(p2 => Math.abs(p1 - p2) <= maxDistance));
+  }
+  
+  // For multiple tokens, find sequence within range
+  const findSequence = (remaining, currentPos) => {
+    if (remaining.length === 0) return true;
+    const [nextPositions, ...rest] = remaining;
+    return nextPositions.some(pos => 
+      Math.abs(pos - currentPos) <= maxDistance && 
+      findSequence(rest, pos)
+    );
+  };
+  
+  const [firstPositions, ...rest] = tokenPositions;
+  return firstPositions.some(startPos => findSequence(rest, startPos));
+}
+

Indexing and Storage

+

Database Schema Optimization

-- Relevant indexes for search performance
+CREATE INDEX idx_notes_type ON notes(type);
+CREATE INDEX idx_notes_isDeleted ON notes(isDeleted);
+CREATE INDEX idx_notes_dateCreated ON notes(dateCreated);
+CREATE INDEX idx_notes_dateModified ON notes(dateModified);
+CREATE INDEX idx_attributes_name ON attributes(name);
+CREATE INDEX idx_attributes_type ON attributes(type);
+CREATE INDEX idx_attributes_value ON attributes(value);
+

Content Processing

+

Notes are processed differently based on type:

// Content preprocessing by note type
+function preprocessContent(content, type, mime) {
+  content = normalize(content.toString());
+  
+  if (type === "text" && mime === "text/html") {
+    content = stripTags(content);
+    content = content.replace(/&nbsp;/g, " ");
+  } else if (type === "mindMap" && mime === "application/json") {
+    content = processMindmapContent(content);
+  } else if (type === "canvas" && mime === "application/json") {
+    const canvasData = JSON.parse(content);
+    const textElements = canvasData.elements
+      .filter(el => el.type === "text" && el.text)
+      .map(el => el.text);
+    content = normalize(textElements.join(" "));
+  }
+  
+  return content.trim();
+}
+

Search Result Processing

+

Scoring Algorithm

+

Results are scored based on multiple factors:

function computeScore(fulltextQuery, highlightedTokens, enableFuzzyMatching) {
+  let score = 0;
+  
+  // Title matches get higher score
+  if (this.noteTitle.toLowerCase().includes(fulltextQuery.toLowerCase())) {
+    score += 10;
+  }
+  
+  // Path matches (hierarchical context)
+  const pathMatch = this.notePathArray.some(pathNote => 
+    pathNote.title.toLowerCase().includes(fulltextQuery.toLowerCase())
+  );
+  if (pathMatch) score += 5;
+  
+  // Attribute matches
+  score += this.attributeMatches * 3;
+  
+  // Content snippet quality
+  if (this.contentSnippet && this.contentSnippet.length > 0) {
+    score += 2;
+  }
+  
+  // Fuzzy match penalty
+  if (enableFuzzyMatching && this.isFuzzyMatch) {
+    score *= 0.8; // 20% penalty for fuzzy matches
+  }
+  
+  return score;
+}
+

Result Merging

+

Exact and fuzzy results are carefully merged:

function mergeExactAndFuzzyResults(exactResults, fuzzyResults) {
+  // Deduplicate - exact results take precedence
+  const exactNoteIds = new Set(exactResults.map(r => r.noteId));
+  const additionalFuzzyResults = fuzzyResults.filter(r => 
+    !exactNoteIds.has(r.noteId)
+  );
+  
+  // Sort within each category
+  exactResults.sort(byScoreAndDepth);
+  additionalFuzzyResults.sort(byScoreAndDepth);
+  
+  // CRITICAL: Exact matches always come first
+  return [...exactResults, ...additionalFuzzyResults];
+}
+

Performance Monitoring

+

Search Metrics

+

Monitor these performance indicators:

// Performance tracking
+const searchMetrics = {
+  totalQueries: 0,
+  exactSearchTime: 0,
+  fuzzySearchTime: 0,
+  resultCount: 0,
+  cacheHitRate: 0,
+  slowQueries: [] // queries taking > 1 second
+};
+

Memory Usage

+

Track memory consumption:

// Memory monitoring
+const memoryMetrics = {
+  searchCacheSize: 0,
+  activeSearchContexts: 0,
+  largeContentNotes: 0, // notes > 1MB
+  indexSize: 0
+};
+

Query Complexity Analysis

+

Identify expensive queries:

// Query complexity factors
+const complexityFactors = {
+  tokenCount: query.split(' ').length,
+  hasRegex: query.includes('%='),
+  hasFuzzy: query.includes('~=') || query.includes('~*'),
+  hasRelationTraversal: query.includes('.relations.'),
+  hasNestedProperties: (query.match(/\./g) || []).length > 2,
+  hasOrderBy: query.includes('orderBy'),
+  estimatedResultSize: 'unknown'
+};
+

Troubleshooting Performance Issues

+

Common Performance Problems

+

Slow Full-Text Search

// Diagnosis
+- Check note content sizes
+- Verify content type filtering
+- Monitor regex usage
+- Review fuzzy search activation
+
+// Solutions
+- Enable fast search for attribute-only queries
+- Add content size limits
+- Optimize regex patterns
+- Tune fuzzy search thresholds
+

Memory Issues

// Diagnosis
+- Monitor result set sizes
+- Check for large content processing
+- Review search context caching
+- Identify memory leaks
+
+// Solutions
+- Add result limits
+- Implement progressive loading
+- Clear unused search contexts
+- Optimize content preprocessing
+

High CPU Usage

// Diagnosis
+- Profile fuzzy search operations
+- Check edit distance calculations
+- Monitor regex compilation
+- Review phrase proximity matching
+
+// Solutions
+- Increase minimum fuzzy token length
+- Reduce maximum edit distance
+- Cache compiled regexes
+- Limit phrase proximity distance
+

Debugging Tools

+

Debug Mode

+

Enable search debugging:

// Search context with debugging
+const searchContext = new SearchContext({
+  debug: true // Logs expression parsing and execution
+});
+

Output includes:

+ +

Performance Profiling

// Manual performance measurement
+const startTime = Date.now();
+const results = searchService.findResultsWithQuery(query, searchContext);
+const endTime = Date.now();
+console.log(`Search took ${endTime - startTime}ms for ${results.length} results`);
+

Query Analysis

// Analyze query complexity
+function analyzeQuery(query) {
+  return {
+    tokenCount: query.split(/\s+/).length,
+    hasAttributes: /#|\~/.test(query),
+    hasProperties: /note\./.test(query),
+    hasRegex: /%=/.test(query),
+    hasFuzzy: /~[=*]/.test(query),
+    complexity: calculateComplexityScore(query)
+  };
+}
+

Configuration and Tuning

+

Server Configuration

+

Relevant settings in config.ini:

# Search-related settings
+[Search]
+maxContentSize=2097152          # 2MB content limit
+minFuzzyTokenLength=3          # Minimum chars for fuzzy
+maxEditDistance=2              # Edit distance limit
+resultSufficiencyThreshold=5   # Fuzzy activation threshold
+enableProgressiveSearch=true   # Enable progressive strategy
+cacheSearchResults=true        # Cache frequent searches
+
+# Performance settings
+[Performance]
+searchTimeoutMs=30000         # 30 second search timeout
+maxSearchResults=1000         # Hard limit on results
+enableSearchProfiling=false   # Performance logging
+

Runtime Tuning

+

Adjust search behavior programmatically:

// Dynamic configuration
+const searchConfig = {
+  maxContentSize: 1024 * 1024,  // 1MB for faster processing
+  enableFuzzySearch: false,      // Exact only for speed
+  resultLimit: 50,               // Smaller result sets
+  useIndexedPropertiesOnly: true // Skip expensive calculations
+};
+

Best Practices for Performance

+

Query Design

+
    +
  1. Start Specific: Use selective criteria first
  2. +
  3. Limit Results: Always set reasonable limits
  4. +
  5. Use Indexes: Prefer indexed properties for ordering
  6. +
  7. Avoid Regex: Use simple operators when possible
  8. +
  9. Cache Common Queries: Save frequently used searches
  10. +
+

System Administration

+
    +
  1. Monitor Performance: Track slow queries and memory usage
  2. +
  3. Regular Maintenance: Clean up unused notes and attributes
  4. +
  5. Index Optimization: Ensure database indexes are current
  6. +
  7. Content Management: Archive or compress large content
  8. +
+

Development Guidelines

+
    +
  1. Test Performance: Benchmark complex queries
  2. +
  3. Profile Regularly: Identify performance regressions
  4. +
  5. Optimize Incrementally: Make small, measured improvements
  6. +
  7. Document Complexity: Note expensive operations
  8. +
+

Advanced Configuration

+

Custom Search Extensions

+

Extend search functionality with custom expressions:

// Custom expression example
+class CustomDateRangeExp extends Expression {
+  constructor(dateField, startDate, endDate) {
+    super();
+    this.dateField = dateField;
+    this.startDate = startDate;
+    this.endDate = endDate;
+  }
+  
+  execute(inputNoteSet, executionContext, searchContext) {
+    // Custom logic for date range filtering
+    // with optimized performance characteristics
+  }
+}
+

Search Result Caching

+

Implement result caching for frequent queries:

// Simple LRU cache for search results
+class SearchResultCache {
+  constructor(maxSize = 100) {
+    this.cache = new Map();
+    this.maxSize = maxSize;
+  }
+  
+  get(queryKey) {
+    if (this.cache.has(queryKey)) {
+      // Move to end (most recently used)
+      const value = this.cache.get(queryKey);
+      this.cache.delete(queryKey);
+      this.cache.set(queryKey, value);
+      return value;
+    }
+    return null;
+  }
+  
+  set(queryKey, results) {
+    if (this.cache.size >= this.maxSize) {
+      // Remove least recently used
+      const firstKey = this.cache.keys().next().value;
+      this.cache.delete(firstKey);
+    }
+    this.cache.set(queryKey, results);
+  }
+}
+

Next Steps

+ \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation.html new file mode 100644 index 0000000000..dc8bf02750 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation.html @@ -0,0 +1,61 @@ +

Running Trilium on a server lets you access your notes from any device + through a web browser and enables synchronization between multiple Trilium + instances. This guide covers the different ways to install and configure + Trilium on your server.

+

Choose Your Installation Method

+

The easiest way to get started is with Docker, which works on most systems + and architectures. If you prefer not to manage your own server, PikaPods + offers managed hosting.

+

Recommended approaches: +

+ +

Advanced options: +

+ +

All server installations include both desktop and mobile web interfaces.

+

Configuration

+

Trilium stores its configuration in a config.ini file located + in the data directory. To customize + your installation, copy the sample configuration file and modify it:

cp config-sample.ini config.ini
+

You can also use environment variables instead of the config file. This + is particularly useful for Docker deployments. See the configuration guide for + all available options.

+

Changing the Data Directory

+

To store Trilium's data (database, config, backups) in a custom location, + set the TRILIUM_DATA_DIR environment variable:

export TRILIUM_DATA_DIR=/path/to/your/trilium-data
+

Upload Size Limits

+

By default, Trilium limits file uploads to 250MB. You can adjust this + limit based on your needs:

# Increase limit to 450MB
+export MAX_ALLOWED_FILE_SIZE_MB=450
+
+# Remove limit entirely (use with caution)
+export TRILIUM_NO_UPLOAD_LIMIT=true
+

Disabling Authentication

+

See Authentication.

+

Reverse Proxy Setup

+

If you want to access Trilium through a domain name or alongside other + web services, you'll need to configure a reverse proxy. Here's a basic + nginx configuration:

location /trilium/ {
+    proxy_pass http://127.0.0.1:8080/;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection 'upgrade';
+    proxy_set_header Host $host;
+    proxy_cache_bypass $http_upgrade;
+}
+
+# Allow larger file uploads (in server block)
+client_max_body_size 0;  # 0 = unlimited
+

For Apache configuration, see the Apache proxy setup guide.

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Manually.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Manually.html new file mode 100644 index 0000000000..dbf209758c --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Manually.html @@ -0,0 +1,43 @@ + +

Requirements

+

Trilium is a node.js application. Supported (tested) version of node.js + is latest 14.X.X and 16.X.X. Trilium might work with older versions as + well.

+

You can check your node version with this command (node.js needs to be + installed):

node --version
+

If your Linux distribution has only an outdated version of node.js, you + can take a look at the installation instruction on node.js website, which + covers most popular distributions.

+

Dependencies

+

There are some dependencies required. You can see command for Debian and + its derivatives (like Ubuntu) below:

sudo apt install libpng16-16 libpng-dev pkg-config autoconf libtool build-essential nasm libx11-dev libxkbfile-dev
+

Installation

+

Download

+

You can either download source code zip/tar from https://github.com/TriliumNext/Trilium/releases/latest.

+

For the latest version including betas, clone Git repository from main branch with:

git clone -b main https://github.com/triliumnext/trilium.git
+

Installation

cd trilium
+
+# download all node dependencies
+npm install
+
+# make sure the better-sqlite3 binary is there
+npm rebuild
+
+# bundles & minifies frontend JavaScript
+npm run webpack
+

Run

cd trilium
+
+# using nohup to make sure trilium keeps running after user logs out
+nohup TRILIUM_ENV=dev node src/www &
+

The application by default starts up on port 8080, so you can open your + browser and navigate to http://localhost:8080 to + access Trilium (replace "localhost" with your hostname).

+

TLS

+

Don't forget to configure TLS which + is required for secure usage!

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Multiple server instances.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Multiple server instances.html new file mode 100644 index 0000000000..242bbd7328 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Multiple server instances.html @@ -0,0 +1,25 @@ +

Trilium does not support multiple users. In order to have two or more + persons with their own set of notes, multiple server instances must be + set up. It is also not possible to use multiple sync servers.

+

To allow multiple server instances on a single physical server:

+ +

For support or additional context, see the related GitHub Discussion.

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/On NixOS.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/On NixOS.html new file mode 100644 index 0000000000..b965f62fc6 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/On NixOS.html @@ -0,0 +1,15 @@ +

This page describes configuring the Trilium module included in NixOS.

+

Requirements

+

NixOS installation.

+

Configuration

+

Add this to your configuration.nix:

services.trilium-server.enable = true;
+
+# default data directory: /var/lib/trilium
+#services.trilium-server.dataDir = "/var/lib/trilium-sync-server";
+
+# default bind address: 127.0.0.1, port 8080
+#services.trilium-server.host = "0.0.0.0";
+#services.trilium-server.port = 12783;
+

Uncomment any option you would like to change.

+

See the NixOS options list for + more options (including nginx reverse proxy configuration).

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Packaged version for Linux.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Packaged version for Linux.html new file mode 100644 index 0000000000..cd726e251f --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Packaged version for Linux.html @@ -0,0 +1,167 @@ +

This is essentially Trilium sources + node modules + node.js runtime packaged + into one 7z file.

+

Steps

+ +

The problem with above steps is that once you close the SSH connection, + the Trilium process is terminated. To avoid that, you have two options:

+ +

Configure Trilium to auto-run on boot with systemd

+
tar -xvf TriliumNotes-Server-[VERSION]-linux-x64.tar.xz
+sudo mv trilium-linux-x64-server /opt/trilium
+
sudo nano /etc/systemd/system/trilium.service
+
[Unit]
+Description=Trilium Daemon
+After=syslog.target network.target
+
+[Service]
+User=xxx
+Group=xxx
+Type=simple
+ExecStart=/opt/trilium/trilium.sh
+WorkingDirectory=/opt/trilium/
+
+TimeoutStopSec=20
+# KillMode=process leads to error, according to https://www.freedesktop.org/software/systemd/man/systemd.kill.html
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+
sudo systemctl enable --now -q trilium
+ +

Simple Autoupdate for Server

+

Run as the same User Trilium runs

+

if you run as root please remove 'sudo' from the commands

+

requires "jq" apt install jq +

+

It will stop the service above, overwrite everything (i expect no config.ini), + and start service It also creates a version file in the Trilium directory + so it updates only with a newer Version

#!/bin/bash
+
+# Configuration
+REPO="TriliumNext/Trilium"
+PATTERN="TriliumNotes-Server-.*-linux-x64.tar.xz"
+DOWNLOAD_DIR="/var/tmp/trilium_download"
+OUTPUT_DIR="/opt/trilium"
+SERVICE_NAME="trilium"
+VERSION_FILE="$OUTPUT_DIR/version.txt"
+
+# Ensure dependencies are installed
+command -v curl >/dev/null 2>&1 || { echo "Error: curl is required"; exit 1; }
+command -v jq >/dev/null 2>&1 || { echo "Error: jq is required"; exit 1; }
+command -v tar >/dev/null 2>&1 || { echo "Error: tar is required"; exit 1; }
+
+# Create download directory
+mkdir -p "$DOWNLOAD_DIR" || { echo "Error: Cannot create $DOWNLOAD_DIR"; exit 1; }
+
+# Get the latest release version
+LATEST_VERSION=$(curl -sL https://api.github.com/repos/$REPO/releases/latest | jq -r '.tag_name')
+if [ -z "$LATEST_VERSION" ]; then
+  echo "Error: Could not fetch latest release version"
+  exit 1
+fi
+
+# Check current installed version (from version.txt or existing tarball)
+CURRENT_VERSION=""
+if [ -f "$VERSION_FILE" ]; then
+  CURRENT_VERSION=$(cat "$VERSION_FILE")
+elif [ -f "$DOWNLOAD_DIR/TriliumNotes-Server-$LATEST_VERSION-linux-x64.tar.xz" ]; then
+  CURRENT_VERSION="$LATEST_VERSION"
+fi
+
+# Compare versions
+if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
+  echo "Latest version ($LATEST_VERSION) is already installed"
+  exit 0
+fi
+
+# Download the latest release
+LATEST_URL=$(curl -sL https://api.github.com/repos/$REPO/releases/latest | jq -r ".assets[] | select(.name | test(\"$PATTERN\")) | .browser_download_url")
+if [ -z "$LATEST_URL" ]; then
+  echo "Error: No asset found matching pattern '$PATTERN'"
+  exit 1
+fi
+
+FILE_NAME=$(basename "$LATEST_URL")
+FILE_PATH="$DOWNLOAD_DIR/$FILE_NAME"
+
+# Download if not already present
+if [ -f "$FILE_PATH" ]; then
+  echo "Latest release $FILE_NAME already downloaded"
+else
+  curl -LO --output-dir "$DOWNLOAD_DIR" "$LATEST_URL" || { echo "Error: Download failed"; exit 1; }
+  echo "Downloaded $FILE_NAME to $DOWNLOAD_DIR"
+fi
+
+# Extract the tarball
+EXTRACT_DIR="$DOWNLOAD_DIR/extracted"
+mkdir -p "$EXTRACT_DIR"
+tar -xJf "$FILE_PATH" -C "$EXTRACT_DIR" || { echo "Error: Extraction failed"; exit 1; }
+
+# Find the extracted directory (e.g., TriliumNotes-Server-0.97.2-linux-x64)
+INNER_DIR=$(find "$EXTRACT_DIR" -maxdepth 1 -type d -name "TriliumNotes-Server-*-linux-x64" | head -n 1)
+if [ -z "$INNER_DIR" ]; then
+  echo "Error: Could not find extracted directory matching TriliumNotes-Server-*-linux-x64"
+  exit 1
+fi
+
+# Stop the trilium-server service
+if systemctl is-active --quiet "$SERVICE_NAME"; then
+  echo "Stopping $SERVICE_NAME service..."
+  sudo systemctl stop "$SERVICE_NAME" || { echo "Error: Failed to stop $SERVICE_NAME"; exit 1; }
+fi
+
+# Copy contents to /opt/trilium, overwriting existing files
+echo "Copying contents from $INNER_DIR to $OUTPUT_DIR..."
+sudo mkdir -p "$OUTPUT_DIR"
+sudo cp -r "$INNER_DIR"/* "$OUTPUT_DIR"/ || { echo "Error: Copy failed"; exit 1; }
+echo "$LATEST_VERSION" | sudo tee "$VERSION_FILE" >/dev/null
+echo "Files copied to $OUTPUT_DIR"
+
+# Start the trilium-server service
+echo "Starting $SERVICE_NAME service..."
+sudo systemctl start "$SERVICE_NAME" || { echo "Error: Failed to start $SERVICE_NAME"; exit 1; }
+
+# Clean up
+rm -rf "$EXTRACT_DIR"
+echo "Cleanup complete. Trilium updated to $LATEST_VERSION."
+

Common issues

+

Outdated glibc

Error: /usr/lib64/libstdc++.so.6: version `GLIBCXX_3.4.21' not found (required by /var/www/virtual/.../node_modules/@mlink/scrypt/build/Release/scrypt.node)
+    at Object.Module._extensions..node (module.js:681:18)
+    at Module.load (module.js:565:32)
+    at tryModuleLoad (module.js:505:12)
+

If you get an error like this, you need to either upgrade your glibc (typically + by upgrading to up-to-date distribution version) or use some other server installation method.

+

TLS

+

Don't forget to configure TLS, which + is required for secure usage!

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Using Docker.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Using Docker.html new file mode 100644 index 0000000000..0b76d32dd2 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Using Docker.html @@ -0,0 +1,192 @@ +

Official docker images are published on docker hub for AMD64, ARMv7 and ARM64/v8: + https://hub.docker.com/r/triliumnext/trilium/ +

+

Prerequisites

+

Ensure Docker is installed on your system.

+

If you need help installing Docker, reference the Docker Installation Docs +

+

Note: Trilium's Docker container requires root privileges + to operate correctly.

+ +

Running with Docker Compose

+

Grab the latest docker-compose.yml:

wget https://raw.githubusercontent.com/TriliumNext/Trilium/master/docker-compose.yml
+

Optionally, edit the docker-compose.yml file to configure the + container settings prior to starting it. Unless configured otherwise, the + data directory will be ~/trilium-data and the container will + be accessible at port 8080.

+

Start the container:

+

Run the following command to start the container in the background:

docker compose up -d
+

Running without Docker Compose / Further Configuration

+

Pulling the Docker Image

+

To pull the image, use the following command, replacing [VERSION] with + the desired version or tag, such as v0.91.6 or just latest. + (See published tag names at https://hub.docker.com/r/triliumnext/trilium/tags.):

docker pull triliumnext/trilium:v0.91.6
+

Warning: Avoid using the "latest" tag, as it may automatically + upgrade your instance to a new minor version, potentially disrupting sync + setups or causing other issues.

+

Preparing the Data Directory

+

Trilium requires a directory on the host system to store its data. This + directory must be mounted into the Docker container with write permissions.

+

Running the Docker Container

+

Local Access Only

+

Run the container to make it accessible only from the localhost. This + setup is suitable for testing or when using a proxy server like Nginx or + Apache.

sudo docker run -t -i -p 127.0.0.1:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:[VERSION]
+
    +
  1. Verify the container is running using docker ps.
  2. +
  3. Access Trilium via a web browser at 127.0.0.1:8080.
  4. +
+

Local Network Access

+

To make the container accessible only on your local network, first create + a new Docker network:

docker network create -d macvlan -o parent=eth0 --subnet 192.168.2.0/24 --gateway 192.168.2.254 --ip-range 192.168.2.252/27 mynet
+

Then, run the container with the network settings:

docker run --net=mynet -d -p 127.0.0.1:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:-latest
+

To set a different user ID (UID) and group ID (GID) for the saved data, + use the USER_UID and USER_GID environment variables:

docker run --net=mynet -d -p 127.0.0.1:8080:8080 -e "USER_UID=1001" -e "USER_GID=1001" -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:-latest
+

Find the local IP address using docker inspect [container_name] and + access the service from devices on the local network.

docker ps
+docker inspect [container_name]
+

Global Access

+

To allow access from any IP address, run the container as follows:

docker run -d -p 0.0.0.0:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:[VERSION]
+

Stop the container with docker stop <CONTAINER ID>, + where the container ID is obtained from docker ps.

+

Custom Data Directory

+

For a custom data directory, use:

-v ~/YourOwnDirectory:/home/node/trilium-data triliumnext/trilium:[VERSION]
+

If you want to run your instance in a non-default way, please use the + volume switch as follows: -v ~/YourOwnDirectory:/home/node/trilium-data triliumnext/trilium:<VERSION>. + It is important to be aware of how Docker works for volumes, with the first + path being your own and the second the one to virtually bind to. https://docs.docker.com/storage/volumes/ The + path before the colon is the host directory, and the path after the colon + is the container's path. More details can be found in the Docker Volumes Documentation.

+

Reverse Proxy

+
    +
  1. Nginx +
  2. +
  3. Apache +
  4. +
+

Note on --user Directive

+

The --user directive is unsupported. Instead, use the USER_UID and USER_GID environment + variables to set the appropriate user and group IDs.

+

Note on timezones

+

If you are having timezone issues and you are not using docker-compose, + you may need to add a TZ environment variable with the TZ identifier of + your local timezone.

+

Rootless Docker Image

+ +

If you would prefer to run Trilium without having to run the Docker container + as root, you can use either of the provided Debian (default) + and Alpine-based images with the rootless tag. 

+

If you're unsure, stick to the “rootful” Docker image referenced above. +

+

Below are some commands to pull the rootless images:

# For Debian-based image
+docker pull triliumnext/trilium:rootless
+
+# For Alpine-based image
+docker pull triliumnext/trilium:rootless-alpine
+

Why Rootless?

+

Running containers as non-root is a security best practice that reduces + the potential impact of container breakouts. If an attacker manages to + escape the container, they'll only have the permissions of the non-root + user instead of full root access to the host.

+

How It Works

+

The rootless Trilium image:

+
    +
  1. Creates a non-root user (trilium) during build time
  2. +
  3. Configures the application to run as this non-root user
  4. +
  5. Allows runtime customization of the user's UID/GID via Docker's --user flag
  6. +
  7. Does not require a separate Docker entrypoint script
  8. +
+

Usage

+

Using docker-compose (Recommended)

# Run with default UID/GID (1000:1000)
+docker-compose -f docker-compose.rootless.yml up -d
+
+# Run with custom UID/GID (e.g., match your host user)
+TRILIUM_UID=$(id -u) TRILIUM_GID=$(id -g) docker-compose -f docker-compose.rootless.yml up -d
+
+# Specify a custom data directory
+TRILIUM_DATA_DIR=/path/to/your/data TRILIUM_UID=$(id -u) TRILIUM_GID=$(id -g) docker-compose -f docker-compose.rootless.yml up -d
+
+

Using Docker CLI

# Build the image
+docker build -t triliumnext/trilium:rootless -f apps/server/Dockerfile.rootless .
+
+# Run with default UID/GID (1000:1000)
+docker run -d --name trilium -p 8080:8080 -v ~/trilium-data:/home/trilium/trilium-data triliumnext/trilium:rootless
+
+# Run with custom UID/GID
+docker run -d --name trilium -p 8080:8080 --user $(id -u):$(id -g) -v ~/trilium-data:/home/trilium/trilium-data triliumnext/trilium:rootless
+
+

Environment Variables

+ +

For a complete list of configuration environment variables (network settings, + authentication, sync, etc.), see Configuration (config.ini or environment variables).

+

Volume Permissions

+

If you encounter permission issues with the data volume, ensure that:

+
    +
  1. The host directory has appropriate permissions for the UID/GID you're + using
  2. +
  3. You're setting both TRILIUM_UID and TRILIUM_GID to + match the owner of the host directory
  4. +
# For example, if your data directory is owned by UID 1001 and GID 1001:
+TRILIUM_UID=1001 TRILIUM_GID=1001 docker-compose -f docker-compose.rootless.yml up -d
+
+

Considerations

+ +

Available Rootless Images

+

Two rootless variants are provided:

+
    +
  1. Debian-based (default): Uses the Debian Bullseye Slim + base image + +
  2. +
  3. Alpine-based: Uses the Alpine base image for smaller + size + +
  4. +
+

Building Custom Rootless Images

+

If you would prefer, you can also customize the UID/GID at build time:

# For Debian-based image with custom UID/GID
+docker build --build-arg USER=myuser --build-arg UID=1001 --build-arg GID=1001 \
+  -t triliumnext/trilium:rootless-custom -f apps/server/Dockerfile.rootless .
+
+# For Alpine-based image with custom UID/GID
+docker build --build-arg USER=myuser --build-arg UID=1001 --build-arg GID=1001 \
+  -t triliumnext/trilium:alpine-rootless-custom -f apps/server/Dockerfile.alpine.rootless .
+
+

Available build arguments:

+ \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Using Kubernetes.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Using Kubernetes.html new file mode 100644 index 0000000000..0fb29eb7ed --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/1. Installing the server/Using Kubernetes.html @@ -0,0 +1,33 @@ +

As Trilium can be run in Docker it also can be deployed in Kubernetes. + You can either use our Helm chart, a community Helm chart, or roll your + own Kubernetes deployment.

+

The recommended way is to use a Helm chart.

+

Root privileges

+ +

The Trilium docker container needs to be run with root privileges. The + node process inside the container will be started with reduced privileges + (uid:gid 1000:1000) after some initialization logic. Please make sure that + you don't use a security context (PodSecurityContext) which changes the + user ID. To use a different uid:gid for file storage and the application, + please use the USER_UID & USER_GID environment + variables.

+

The docker image will also fix the permissions of /home/node so + you don't have to use an init container.

+

Helm Charts

+

Official Helm chart from + TriliumNext Unofficial helm chart by ohdearaugustin: + https://github.com/ohdearaugustin/charts +

+

Adding a Helm repository

+

Below is an example of how

helm repo add trilium https://triliumnext.github.io/helm-charts
+"trilium" has been added to your repositories
+

How to install a chart

+

After reviewing the values.yaml from + the Helm chart, modifying as required and then creating your own:

helm install --create-namespace --namespace trilium trilium trilium/trilium -f values.yaml
+

For more information on using Helm, please refer to the Helm documentation, + or create a Discussion in the TriliumNext GitHub Organization.

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/2. Reverse proxy/Apache.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/2. Reverse proxy/Apache.html new file mode 100644 index 0000000000..4e2bedc8aa --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/2. Reverse proxy/Apache.html @@ -0,0 +1,79 @@ +

I've assumed you have created a DNS A record for trilium.yourdomain.com that + you want to use for your Trilium server.

+
    +
  1. +

    Download docker image and create container

     docker pull triliumnext/trilium:[VERSION]
    + docker create --name trilium -t -p 127.0.0.1:8080:8080 -v ~/trilium-data:/home/node/trilium-data triliumnext/trilium:[VERSION]
    +
  2. +
  3. +

    Configure Apache proxy and websocket proxy

    +
      +
    1. +

      Enable apache proxy modules

       a2enmod ssl
      + a2enmod proxy
      + a2enmod proxy_http
      + a2enmod proxy_wstunnel
      +
    2. +
    3. +

      Create a new let's encrypt certificate

       sudo certbot certonly -d trilium.mydomain.com
      +

      Choose standalone (2) and note the location of the created certificates + (typically /etc/letsencrypt/live/...)

      +
    4. +
    5. +

      Create a new virtual host file for apache (you may want to use apachectl -S to + determine the server root location, mine is /etc/apache2)

       sudo nano /etc/apache2/sites-available/trilium.yourdomain.com.conf
      +

      Paste (and customize) the following text into the configuration file

       
      +     ServerName http://trilium.yourdomain.com
      +     RewriteEngine on
      +         RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,QSA,R=permanent]
      + 
      + 
      +     ServerName https://trilium.yourdomain.com
      +     RewriteEngine On
      +     RewriteCond %{HTTP:Connection} Upgrade [NC]
      +     RewriteCond %{HTTP:Upgrade} websocket [NC]
      +     RewriteRule /(.*) ws://localhost:8080/$1 [P,L]
      +     AllowEncodedSlashes NoDecode
      +     ProxyPass / http://localhost:8080/ nocanon
      +     ProxyPassReverse / http://localhost:8080/
      +     SSLCertificateFile /etc/letsencrypt/live/trilium.yourdomain.com/fullchain.pem
      +     SSLCertificateKeyFile /etc/letsencrypt/live/trilium.yourdomain.com/privkey.pem
      +     Include /etc/letsencrypt/options-ssl-apache.conf
      + 
      +
    6. +
    7. +

      Enable the virtual host with sudo a2ensite trilium.yourdomain.com.conf +

      +
    8. +
    9. +

      Reload apache2 with sudo systemctl reload apache2 +

      +
    10. +
    +
  4. +
  5. +

    Create and enable a systemd service to start the docker container on boot

    +
      +
    1. +

      Create a new empty file called /lib/systemd/system/trilium.service with + the contents

       [Unit]
      + Description=Trilium Server
      + Requires=docker.service
      + After=docker.service
      +
      + [Service]
      + Restart=always
      + ExecStart=/usr/bin/docker start -a trilium
      + ExecStop=/usr/bin/docker stop -t 2 trilium
      +
      + [Install]
      + WantedBy=local.target
      +
    2. +
    3. +

      Install, enable and start service

       sudo systemctl daemon-reload
      + sudo systemctl enable trilium.service
      + sudo systemctl start trilium.service
      +
    4. +
    +
  6. +
\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/2. Reverse proxy/Nginx.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/2. Reverse proxy/Nginx.html new file mode 100644 index 0000000000..7b7e958a0d --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/2. Reverse proxy/Nginx.html @@ -0,0 +1,74 @@ +

Configure Nginx proxy and HTTPS. The operating system here is Ubuntu 18.04.

+
    +
  1. +

    Download Nginx and remove Apache2

    sudo apt-get install nginx
    +sudo apt-get remove apache2
    +
  2. +
  3. +

    Create configure file

    cd /etc/nginx/conf.d
    +vim default.conf
    +
  4. +
  5. +

    Fill the file with the context shown below, part of the setting show be + changed. Then you can enjoy your web with HTTPS forced and proxy.

    # This part configures, where your Trilium server is running
    +upstream trilium {
    +  zone trilium 64k;
    +  server 127.0.0.1:8080; # change it to a different hostname and port if non-default is used
    +  keepalive 2;
    +}
    +
    +# This part is for proxy and HTTPS configure
    +server {
    +    listen 443 ssl;
    +    server_name trilium.example.net; #change trilium.example.net to your domain without HTTPS or HTTP.
    +    ssl_certificate /etc/ssl/note/example.crt; #change /etc/ssl/note/example.crt to your path of crt file.
    +    ssl_certificate_key /etc/ssl/note/example.net.key; #change /etc/ssl/note/example.net.key to your path of key file.
    +    ssl_session_cache builtin:1000 shared:SSL:10m;
    +    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    +    ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
    +    ssl_prefer_server_ciphers on;
    +    access_log /var/log/nginx/access.log; #check the path of access.log, if it doesn't fit your file, change it
    +
    +    location / {
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Real-IP $remote_addr;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_pass http://trilium;
    +        proxy_read_timeout 90;
    +    }
    +}
    +
    +# This part is for HTTPS forced
    +server {
    +    listen 80;
    +    server_name trilium.example.net; # change to your domain
    +    return 301 https://$server_name$request_uri;
    +}
    +
  6. +
  7. +

    Alternatively if you want to serve the instance under a different path + (useful e.g. if you want to serve multiple instances), update the location + block like so:

    +
        location /trilium/instance-one {
    +        rewrite /trilium/instance-one/(.*) /$1  break;
    +        proxy_set_header Host $host;
    +        proxy_set_header X-Real-IP $remote_addr;
    +        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    +        proxy_set_header X-Forwarded-Proto $scheme;
    +        proxy_set_header Upgrade $http_upgrade;
    +        proxy_set_header Connection "upgrade";
    +        proxy_pass http://trilium;
    +        proxy_cookie_path / /trilium/instance-one
    +        proxy_read_timeout 90;
    +    }
    +
    +
  8. +
\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/Authentication.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/Authentication.html new file mode 100644 index 0000000000..def51c52ee --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/Authentication.html @@ -0,0 +1,35 @@ +

Disabling authentication

+

If you are running Trilium on localhost only or if authentication + is handled by another component, you can disable Trilium’s authentication + by adding the following to config.ini:

[General]
+noAuthentication=true
+

Disabling authentication will bypass even the Multi-Factor Authentication since + v0.94.1.

+

Understanding how the session works

+

Once logged into Trilium, the application will store this information + about the login into a cookie on the browser, but also as a session on + the server.

+

If “Remember me” is checked, then the login will expire in 21 days. This + period can be adjusted by modifying the Session.cookieMaxAge value + in config.ini. For example, to have the session expire in one + day:

[Session]
+cookieMaxAge=86400
+

When “Remember me” is unchecked, the behavior is different. At client/browser + level the authentication does not have any expiration date, but it will + be automatically cleared as soon as the user closes the browser. Nevertheless, + the server will also dismiss this authentication in around 24 hours from + the last interaction with the application.

+

Viewing active sessions

+

The login sessions are now stored in the same Database as the user data. In + order to view which sessions are active, open the SQL Console and run the following + query:

SELECT * FROM sessions
+

Expired sessions are periodically cleaned by the server, generally an + hourly interval.

+

See also

+ \ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/Multi-Factor Authentication.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/Multi-Factor Authentication.html new file mode 100644 index 0000000000..408c60c333 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/Multi-Factor Authentication.html @@ -0,0 +1,103 @@ +

Multi-factor authentication (MFA) is a security process that requires + users to provide two or more verification factors to gain access to a system, + application, or account. This adds an extra layer of protection beyond + just using a password.

+

By requiring more than one verification method, MFA helps reduce the risk + of unauthorized access, even if someone has obtained your password. It’s + highly recommended for securing sensitive information stored in your notes.

+ +

Log in with your Google Account with OpenID!

+

OpenID is a standardized way to let you log into websites using an account + from another service, like Google, to verify your identity.

+

Why Time-based One Time Passwords?

+

TOTP (Time-Based One-Time Password) is a security feature that generates + a unique, temporary code on your device, like a smartphone, which changes + every 30 seconds. You use this code, along with your password, to log into + your account, making it much harder for anyone else to access them.

+

Setup

+

MFA can only be set up on a server instance.

+ +

TOTP

+
    +
  1. Go to "Menu" -> "Options" -> "MFA"
  2. +
  3. Click the “Enable Multi-Factor Authentication” checkbox if not checked
  4. +
  5. Choose “Time-Based One-Time Password (TOTP)” under MFA Method
  6. +
  7. Click the "Generate TOTP Secret" button
  8. +
  9. Copy the generated secret to your authentication app/extension
  10. +
  11. Click the "Generate Recovery Codes" button
  12. +
  13. Save the recovery codes. Recovery codes can be used once in place of the + TOTP if you loose access to your authenticator. After a rerecovery code + is used, it will show the unix timestamp when it was used in the MFA options + tab.
  14. +
  15. Re-login will be required after TOTP setup is finished (After you refreshing + the page).
  16. +
+

OpenID

+

In order to setup OpenID, you will need to setup a authentication provider. + This requires a bit of extra setup. Follow these instructions to + setup an OpenID service through google. The Redirect URL of Trilium is https://<your-trilium-domain>/callback.

+
    +
  1. Set the oauthBaseUrl, oauthClientId and oauthClientSecret in + the config.ini file (check Configuration (config.ini or environment variables) for + more information). +
      +
    1. You can also setup through environment variables: +
        +
      • Standard: TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHBASEURL, TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTID, TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTSECRET +
      • +
      • Legacy (still supported): TRILIUM_OAUTH_BASE_URL, TRILIUM_OAUTH_CLIENT_ID, TRILIUM_OAUTH_CLIENT_SECRET +
      • +
      +
    2. +
    3. oauthBaseUrl should be the link of your Trilium instance server, + for example, https://<your-trilium-domain>.
    4. +
    +
  2. +
  3. Restart the server
  4. +
  5. Go to "Menu" -> "Options" -> "MFA"
  6. +
  7. Click the “Enable Multi-Factor Authentication” checkbox if not checked
  8. +
  9. Choose “OAuth/OpenID” under MFA Method
  10. +
  11. Refresh the page and login through OpenID provider
  12. +
+ +

Authentik

+

If you don’t already have a running Authentik instance, please follow + these instructionsto set one up.

+
    +
  1. In the Authentik admin dashboard, create a new OAuth2 application by following + these steps. Make sure to set the Redirect URL to: https://<your-trilium-domain>/callback.
  2. +
  3. In your config.ini file, set the relevant OAuth variables: +
      +
    1. oauthIssuerBaseUrl → Use the OpenID Configuration Issuer URL + from your application's overview page.
    2. +
    3. oauthIssuerName and oauthIssuerIcon → Set these + to customize the name and icon displayed on the login page. If omitted, + Google’s name and icon will be shown by default.
    4. +
    +
  4. +
  5. Apply the changes by restarting your server.
  6. +
  7. Proceed with the remaining steps starting from Step 3 in the OpenID section.
  8. +
\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/TLS Configuration.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/TLS Configuration.html new file mode 100644 index 0000000000..866ebc0fe9 --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/1_Server Installation/TLS Configuration.html @@ -0,0 +1,48 @@ +

Configuring TLS is essential for server installation in + Trilium. This guide details the steps to set up TLS within Trilium itself.

+

For a more robust solution, consider using TLS termination with a reverse + proxy (recommended, e.g., Nginx). You can follow a guide like this for + such setups.

+

Obtaining a TLS Certificate

+

You have two options for obtaining a TLS certificate:

+ +

Modifying config.ini

+

Once you have your certificate, modify the config.ini file + in the data directory to configure + Trilium to use it:

[Network]
+port=8080
+# Set to true for TLS/SSL/HTTPS (secure), false for HTTP (insecure).
+https=true
+# Path to the certificate (run "bash bin/generate-cert.sh" to generate a self-signed certificate).
+# Relevant only if https=true
+certPath=/[username]/.acme.sh/[hostname]/fullchain.cer
+keyPath=/[username]/.acme.sh/[hostname]/example.com.key
+

You can also review the configuration file + to provide all config.ini values as environment variables instead. + For example, you can configure TLS using environment variables:

export TRILIUM_NETWORK_HTTPS=true
+export TRILIUM_NETWORK_CERTPATH=/path/to/cert.pem
+export TRILIUM_NETWORK_KEYPATH=/path/to/key.pem
+

The above example shows how this is set up in an environment where the + certificate was generated using Let's Encrypt's ACME utility. Your paths + may differ. For Docker installations, ensure these paths are within a volume + or another directory accessible by the Docker container, such as /home/node/trilium-data/[DIR IN DATA DIRECTORY].

+

After configuring config.ini, restart Trilium and access the + hostname using "https".

+

Self-Signed Certificate

+

If you opt to use a self-signed certificate for your server instance, + note that the desktop instance will not trust it by default.

+

To bypass this, disable certificate validation by setting the following + environment variable (for Linux):

export NODE_TLS_REJECT_UNAUTHORIZED=0
+trilium
+

Trilium provides scripts to start in this mode, such as trilium-no-cert-check.bat for + Windows.

+

Warning: Disabling TLS certificate validation is insecure. + Proceed only if you fully understand the implications.

\ No newline at end of file diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Security/Protected Notes and Encryption.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Security/Protected Notes and Encryption.html new file mode 100644 index 0000000000..cdd405eb6d --- /dev/null +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Security/Protected Notes and Encryption.html @@ -0,0 +1,276 @@ +

Trilium provides robust encryption capabilities through its Protected + Notes system, ensuring your sensitive information remains secure even if + your database is compromised.

+

Overview

+

Protected notes in Trilium use AES-128-CBC encryption with + scrypt-based key derivation to protect sensitive content. The encryption + is designed to be:

+ +

How Encryption Works

+

Encryption Algorithm

+ +

Key Management

+
    +
  1. Master Password: User-provided password used for key + derivation
  2. +
  3. Data Key: 32-byte random key generated during setup, + encrypted with password-derived key
  4. +
  5. Password-Derived Key: Generated using scrypt from master + password and salt
  6. +
  7. Session Key: Data key loaded into memory during protected + session
  8. +
+

Encryption Process

1. Generate random 16-byte IV
+2. Compute SHA-1 digest of plaintext (use first 4 bytes)
+3. Prepend digest to plaintext
+4. Encrypt (digest + plaintext) using AES-128-CBC
+5. Prepend IV to encrypted data
+6. Encode result as Base64
+

Decryption Process

1. Decode Base64 ciphertext
+2. Extract IV (first 16 bytes) and encrypted data
+3. Decrypt using AES-128-CBC with data key and IV
+4. Extract digest (first 4 bytes) and plaintext
+5. Verify integrity by comparing computed vs. stored digest
+6. Return plaintext if verification succeeds
+

Setting Up Protected Notes

+

Initial Setup

+
    +
  1. Set Master Password: Configure a strong password during + initial setup
  2. +
  3. Create Protected Note: Right-click a note and select + "Toggle Protected Status"
  4. +
  5. Enter Protected Session: Click the shield icon or use + Ctrl+Shift+P
  6. +
+

Password Requirements

+ +

Best Practices

+
    +
  1. Strong Passwords: Use passphrases or generated passwords
  2. +
  3. Regular Changes: Update passwords periodically
  4. +
  5. Secure Storage: Store password recovery information securely
  6. +
  7. Backup Strategy: Ensure encrypted backups are properly + secured
  8. +
+

Protected Sessions

+

Session Management

+ +

Session Lifecycle

+
    +
  1. Enter Session: User enters master password
  2. +
  3. Key Derivation: System derives data key from password
  4. +
  5. Session Active: Protected content accessible in plaintext
  6. +
  7. Timeout/Logout: Data key removed from memory
  8. +
  9. Protection Restored: Content returns to encrypted state
  10. +
+

Configuration Options

+

Access via Options → Protected Session:

+ +

Performance Considerations

+

Encryption Overhead

+ +

Optimization Tips

+
    +
  1. Selective Protection: Only encrypt truly sensitive notes
  2. +
  3. Session Management: Keep sessions active during intensive + work
  4. +
  5. Hardware Acceleration: Modern CPUs provide AES acceleration
  6. +
  7. Batch Operations: Group protected note operations when + possible
  8. +
+

Security Considerations

+

Threat Model

+

Protected Against:

+ +

Not Protected Against:

+ +

Limitations

+
    +
  1. Note Titles: Currently encrypted, may leak structural + information
  2. +
  3. Metadata: Creation dates, modification times remain unencrypted
  4. +
  5. Search Indexing: Protected notes excluded from full-text + search
  6. +
  7. Sync Conflicts: May be harder to resolve for protected + content
  8. +
+

Troubleshooting

+

Common Issues

+

"Could not decrypt string" Error

+

Causes:

+ +

Solutions:

+
    +
  1. Verify password spelling and case sensitivity
  2. +
  3. Check for active protected session
  4. +
  5. Restart application and retry
  6. +
  7. Restore from backup if corruption suspected
  8. +
+

Protected Session Won't Start

+

Causes:

+ +

Solutions:

+
    +
  1. Check error logs for specific error messages
  2. +
  3. Verify database integrity
  4. +
  5. Restore from known good backup
  6. +
  7. Contact support with error details
  8. +
+

Performance Issues

+

Symptoms:

+ +

Solutions:

+
    +
  1. Reduce scrypt parameters (advanced users only)
  2. +
  3. Limit number of protected notes
  4. +
  5. Upgrade hardware (more RAM/faster CPU)
  6. +
  7. Close other resource-intensive applications
  8. +
+

Recovery Procedures

+

Password Recovery

+

If you forget your master password:

+
    +
  1. No Built-in Recovery: Trilium cannot recover forgotten + passwords
  2. +
  3. Backup Restoration: Restore from backup with known password
  4. +
  5. Data Export: Export unprotected content before password + change
  6. +
  7. Complete Reset: Last resort - lose all protected content
  8. +
+

Data Recovery

+

For corrupted protected notes:

+
    +
  1. Verify Backup: Check if backups contain uncorrupted data
  2. +
  3. Export/Import: Try exporting and re-importing the note
  4. +
  5. Database Repair: Use database repair tools if available
  6. +
  7. Professional Help: Contact data recovery services for + critical data
  8. +
+

Advanced Configuration

+

Custom Encryption Parameters

+

Warning: Modifying encryption parameters requires advanced + knowledge and may break compatibility.

+

For expert users, encryption parameters can be modified in the source + code:

// In my_scrypt.ts
+const scryptParams = {
+    N: 16384,  // CPU/memory cost parameter
+    r: 8,      // Block size parameter  
+    p: 1       // Parallelization parameter
+};
+

Integration with External Tools

+

Protected notes can be accessed programmatically:

// Backend script example
+const protectedNote = api.getNote('noteId');
+if (protectedNote.isProtected) {
+    // Content will be encrypted unless in protected session
+    const content = protectedNote.getContent();
+}
+

Compliance and Auditing

+

Encryption Standards

+ +

Audit Trail

+ +

Compliance Considerations

+ +

Migration and Backup

+

Backup Strategies

+
    +
  1. Encrypted Backups: Regular backups preserve encrypted + state
  2. +
  3. Unencrypted Exports: Export protected content during + session
  4. +
  5. Key Management: Securely store password recovery information
  6. +
  7. Testing: Regularly test backup restoration procedures
  8. +
+

Migration Procedures

+

When moving to new installation:

+
    +
  1. Export Data: Export all notes including protected content
  2. +
  3. Backup Database: Create complete database backup
  4. +
  5. Transfer Files: Move exported files to new installation
  6. +
  7. Import Data: Import using same master password
  8. +
  9. Verify: Confirm all protected content accessible
  10. +
+

Remember: The security of protected notes ultimately depends on choosing + a strong master password and following security best practices for your + overall system.

\ No newline at end of file diff --git a/docs/Developer Guide/!!!meta.json b/docs/Developer Guide/!!!meta.json index aee04e341c..8cd2e71bf3 100644 --- a/docs/Developer Guide/!!!meta.json +++ b/docs/Developer Guide/!!!meta.json @@ -1,6 +1,6 @@ { "formatVersion": 2, - "appVersion": "0.97.2", + "appVersion": "0.98.0", "files": [ { "isClone": false, @@ -19,6 +19,503 @@ "attachments": [], "dirFileName": "Developer Guide", "children": [ + { + "isClone": false, + "noteId": "YNX9NN659vF2", + "notePath": [ + "jdjRLhLV3TtI", + "YNX9NN659vF2" + ], + "title": "Architecture", + "notePosition": 80, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/html", + "attributes": [], + "format": "markdown", + "attachments": [], + "dirFileName": "Architecture", + "children": [ + { + "isClone": false, + "noteId": "yfKVWBgPMQj4", + "notePath": [ + "jdjRLhLV3TtI", + "YNX9NN659vF2", + "yfKVWBgPMQj4" + ], + "title": "API-Architecture", + "notePosition": 10, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [ + { + "type": "relation", + "name": "internalLink", + "value": "1HpEf2wiYVmV", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "9GSipWxTZ81I", + "isInheritable": false, + "position": 10 + } + ], + "format": "markdown", + "dataFileName": "API-Architecture.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "9GSipWxTZ81I", + "notePath": [ + "jdjRLhLV3TtI", + "YNX9NN659vF2", + "9GSipWxTZ81I" + ], + "title": "Entity-System", + "notePosition": 20, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [ + { + "type": "relation", + "name": "internalLink", + "value": "1HpEf2wiYVmV", + "isInheritable": false, + "position": 10 + } + ], + "format": "markdown", + "dataFileName": "Entity-System.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "QR0jtNmTvYqx", + "notePath": [ + "jdjRLhLV3TtI", + "YNX9NN659vF2", + "QR0jtNmTvYqx" + ], + "title": "Monorepo-Structure", + "notePosition": 30, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [], + "format": "markdown", + "dataFileName": "Monorepo-Structure.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "KQT5Xda5Czcl", + "notePath": [ + "jdjRLhLV3TtI", + "YNX9NN659vF2", + "KQT5Xda5Czcl" + ], + "title": "README", + "notePosition": 40, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [ + { + "type": "relation", + "name": "internalLink", + "value": "1HpEf2wiYVmV", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "9GSipWxTZ81I", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "NZV7rSwYe0dV", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "yfKVWBgPMQj4", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "QR0jtNmTvYqx", + "isInheritable": false, + "position": 10 + } + ], + "format": "markdown", + "dataFileName": "README.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "1HpEf2wiYVmV", + "notePath": [ + "jdjRLhLV3TtI", + "YNX9NN659vF2", + "1HpEf2wiYVmV" + ], + "title": "Three-Layer-Cache-System", + "notePosition": 50, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [ + { + "type": "relation", + "name": "internalLink", + "value": "9GSipWxTZ81I", + "isInheritable": false, + "position": 10 + } + ], + "format": "markdown", + "dataFileName": "Three-Layer-Cache-System.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "NZV7rSwYe0dV", + "notePath": [ + "jdjRLhLV3TtI", + "YNX9NN659vF2", + "NZV7rSwYe0dV" + ], + "title": "Widget-Based-UI-Architecture", + "notePosition": 60, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [], + "format": "markdown", + "dataFileName": "Widget-Based-UI-Architecture.md", + "attachments": [] + } + ] + }, + { + "isClone": false, + "noteId": "TfoaMQkD75JI", + "notePath": [ + "jdjRLhLV3TtI", + "TfoaMQkD75JI" + ], + "title": "API Documentation", + "notePosition": 90, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/html", + "attributes": [], + "format": "markdown", + "attachments": [], + "dirFileName": "API Documentation", + "children": [ + { + "isClone": false, + "noteId": "tvrq35NqaWce", + "notePath": [ + "jdjRLhLV3TtI", + "TfoaMQkD75JI", + "tvrq35NqaWce" + ], + "title": "API Client Libraries", + "notePosition": 10, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [ + { + "type": "relation", + "name": "internalLink", + "value": "oB5PbEHAzG9t", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "eDWmn0B42ALV", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "RqCs8jiZGB1g", + "isInheritable": false, + "position": 10 + } + ], + "format": "markdown", + "dataFileName": "API Client Libraries.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "oB5PbEHAzG9t", + "notePath": [ + "jdjRLhLV3TtI", + "TfoaMQkD75JI", + "oB5PbEHAzG9t" + ], + "title": "ETAPI Complete Guide", + "notePosition": 20, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [], + "format": "markdown", + "dataFileName": "ETAPI Complete Guide.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "4sxya4t9P8AA", + "notePath": [ + "jdjRLhLV3TtI", + "TfoaMQkD75JI", + "4sxya4t9P8AA" + ], + "title": "Internal API Reference", + "notePosition": 30, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [ + { + "type": "relation", + "name": "internalLink", + "value": "oB5PbEHAzG9t", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "RqCs8jiZGB1g", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "eDWmn0B42ALV", + "isInheritable": false, + "position": 10 + } + ], + "format": "markdown", + "dataFileName": "Internal API Reference.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "RqCs8jiZGB1g", + "notePath": [ + "jdjRLhLV3TtI", + "TfoaMQkD75JI", + "RqCs8jiZGB1g" + ], + "title": "Script API Cookbook", + "notePosition": 40, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [], + "format": "markdown", + "dataFileName": "Script API Cookbook.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "eDWmn0B42ALV", + "notePath": [ + "jdjRLhLV3TtI", + "TfoaMQkD75JI", + "eDWmn0B42ALV" + ], + "title": "WebSocket API", + "notePosition": 50, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [ + { + "type": "relation", + "name": "internalLink", + "value": "4sxya4t9P8AA", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "oB5PbEHAzG9t", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "tvrq35NqaWce", + "isInheritable": false, + "position": 10 + } + ], + "format": "markdown", + "dataFileName": "WebSocket API.md", + "attachments": [] + } + ] + }, + { + "isClone": false, + "noteId": "bPMgREWrBYg4", + "notePath": [ + "jdjRLhLV3TtI", + "bPMgREWrBYg4" + ], + "title": "Plugin Development", + "notePosition": 100, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/html", + "attributes": [], + "format": "markdown", + "attachments": [], + "dirFileName": "Plugin Development", + "children": [ + { + "isClone": false, + "noteId": "bZEM5pnOzTVt", + "notePath": [ + "jdjRLhLV3TtI", + "bPMgREWrBYg4", + "bZEM5pnOzTVt" + ], + "title": "Backend Script Development", + "notePosition": 10, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [], + "format": "markdown", + "dataFileName": "Backend Script Development.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "fI5sL6zd3n9K", + "notePath": [ + "jdjRLhLV3TtI", + "bPMgREWrBYg4", + "fI5sL6zd3n9K" + ], + "title": "Custom Note Type Development", + "notePosition": 20, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [], + "format": "markdown", + "dataFileName": "Custom Note Type Development.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "gRXGO3vGuey9", + "notePath": [ + "jdjRLhLV3TtI", + "bPMgREWrBYg4", + "gRXGO3vGuey9" + ], + "title": "Custom Widget Development Guide", + "notePosition": 30, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [], + "format": "markdown", + "dataFileName": "Custom Widget Development Guid.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "3ZeRzd0e7Btc", + "notePath": [ + "jdjRLhLV3TtI", + "bPMgREWrBYg4", + "3ZeRzd0e7Btc" + ], + "title": "Frontend Script Development", + "notePosition": 40, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [], + "format": "markdown", + "dataFileName": "Frontend Script Development.md", + "attachments": [] + }, + { + "isClone": false, + "noteId": "dZnOJuAVLKxt", + "notePath": [ + "jdjRLhLV3TtI", + "bPMgREWrBYg4", + "dZnOJuAVLKxt" + ], + "title": "Theme Development Guide", + "notePosition": 50, + "prefix": null, + "isExpanded": false, + "type": "text", + "mime": "text/markdown", + "attributes": [], + "format": "markdown", + "dataFileName": "Theme Development Guide.md", + "attachments": [] + } + ] + }, { "isClone": false, "noteId": "T2W7WCZrYZBU", @@ -27,7 +524,7 @@ "T2W7WCZrYZBU" ], "title": "Environment Setup", - "notePosition": 50, + "notePosition": 130, "prefix": null, "isExpanded": false, "type": "text", @@ -53,7 +550,7 @@ "cxfTSHIUQtt2" ], "title": "Project Structure", - "notePosition": 190, + "notePosition": 270, "prefix": null, "isExpanded": false, "type": "text", @@ -139,7 +636,7 @@ "YjerxU7Aii8X" ], "title": "Troubleshooting", - "notePosition": 200, + "notePosition": 280, "prefix": null, "isExpanded": false, "type": "text", @@ -186,7 +683,7 @@ "wbVIolLKDhe2" ], "title": "Development and architecture", - "notePosition": 220, + "notePosition": 300, "prefix": null, "isExpanded": false, "type": "text", @@ -1728,7 +2225,7 @@ "VHhyVRYK43gI" ], "title": "Building and deployment", - "notePosition": 230, + "notePosition": 310, "prefix": null, "isExpanded": false, "type": "text", @@ -1767,7 +2264,7 @@ "ibAPHul7Efvr" ], "title": "Old documentation", - "notePosition": 260, + "notePosition": 340, "prefix": null, "isExpanded": false, "type": "text", diff --git a/docs/Developer Guide/Developer Guide/API Documentation/API Client Libraries.md b/docs/Developer Guide/Developer Guide/API Documentation/API Client Libraries.md new file mode 100644 index 0000000000..b331da0991 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/API Client Libraries.md @@ -0,0 +1,2277 @@ +# API Client Libraries +## Table of Contents + +1. [Overview](#overview) +2. [JavaScript/TypeScript Client](#javascripttypescript-client) +3. [Python Client - trilium-py](#python-client---trilium-py) +4. [Go Client](#go-client) +5. [Ruby Client](#ruby-client) +6. [PHP Client](#php-client) +7. [C# Client](#c-client) +8. [Rust Client](#rust-client) +9. [REST Client Best Practices](#rest-client-best-practices) +10. [Error Handling Patterns](#error-handling-patterns) +11. [Retry Strategies](#retry-strategies) +12. [Testing Client Libraries](#testing-client-libraries) + +## Overview + +This guide provides comprehensive examples of Trilium API client libraries in various programming languages. Each implementation follows best practices for that language while maintaining consistent functionality across all clients. + +### Common Features + +All client libraries should implement: + +* Token-based authentication +* CRUD operations for notes, attributes, branches, and attachments +* Search functionality +* Error handling with retry logic +* Connection pooling +* Request/response logging (optional) +* Rate limiting support + +## JavaScript/TypeScript Client + +### Full-Featured TypeScript Implementation + +```typescript +// trilium-client.ts + +import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'; + +// Types +export interface Note { + noteId: string; + title: string; + type: string; + mime: string; + isProtected: boolean; + attributes?: Attribute[]; + parentNoteIds?: string[]; + childNoteIds?: string[]; + dateCreated: string; + dateModified: string; + utcDateCreated: string; + utcDateModified: string; +} + +export interface CreateNoteParams { + parentNoteId: string; + title: string; + type: string; + content: string; + notePosition?: number; + prefix?: string; + isExpanded?: boolean; + noteId?: string; + branchId?: string; +} + +export interface Attribute { + attributeId: string; + noteId: string; + type: 'label' | 'relation'; + name: string; + value: string; + position?: number; + isInheritable?: boolean; +} + +export interface Branch { + branchId: string; + noteId: string; + parentNoteId: string; + prefix?: string; + notePosition?: number; + isExpanded?: boolean; +} + +export interface Attachment { + attachmentId: string; + ownerId: string; + role: string; + mime: string; + title: string; + position?: number; + blobId?: string; + dateModified?: string; + utcDateModified?: string; +} + +export interface SearchParams { + search: string; + fastSearch?: boolean; + includeArchivedNotes?: boolean; + ancestorNoteId?: string; + ancestorDepth?: string; + orderBy?: string; + orderDirection?: 'asc' | 'desc'; + limit?: number; + debug?: boolean; +} + +export interface SearchResponse { + results: Note[]; + debugInfo?: any; +} + +export interface AppInfo { + appVersion: string; + dbVersion: number; + syncVersion: number; + buildDate: string; + buildRevision: string; + dataDirectory: string; + clipperProtocolVersion: string; + utcDateTime: string; +} + +export interface TriliumClientConfig { + baseUrl: string; + token: string; + timeout?: number; + retryAttempts?: number; + retryDelay?: number; + enableLogging?: boolean; +} + +// Error classes +export class TriliumError extends Error { + constructor( + message: string, + public statusCode?: number, + public code?: string, + public details?: any + ) { + super(message); + this.name = 'TriliumError'; + } +} + +export class TriliumConnectionError extends TriliumError { + constructor(message: string, details?: any) { + super(message, undefined, 'CONNECTION_ERROR', details); + this.name = 'TriliumConnectionError'; + } +} + +export class TriliumAuthError extends TriliumError { + constructor(message: string, details?: any) { + super(message, 401, 'AUTH_ERROR', details); + this.name = 'TriliumAuthError'; + } +} + +// Main client class +export class TriliumClient { + private client: AxiosInstance; + private config: Required; + + constructor(config: TriliumClientConfig) { + this.config = { + timeout: 30000, + retryAttempts: 3, + retryDelay: 1000, + enableLogging: false, + ...config + }; + + this.client = axios.create({ + baseURL: this.config.baseUrl, + timeout: this.config.timeout, + headers: { + 'Authorization': this.config.token, + 'Content-Type': 'application/json' + } + }); + + this.setupInterceptors(); + } + + private setupInterceptors(): void { + // Request interceptor for logging + this.client.interceptors.request.use( + (config) => { + if (this.config.enableLogging) { + console.log(`[Trilium] ${config.method?.toUpperCase()} ${config.url}`); + } + return config; + }, + (error) => Promise.reject(error) + ); + + // Response interceptor for error handling and retry + this.client.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as AxiosRequestConfig & { _retryCount?: number }; + + if (!originalRequest) { + throw new TriliumConnectionError('No request config available'); + } + + // Initialize retry count + if (!originalRequest._retryCount) { + originalRequest._retryCount = 0; + } + + // Handle different error types + if (error.response) { + // Server responded with error + if (error.response.status === 401) { + throw new TriliumAuthError('Authentication failed', error.response.data); + } + + // Don't retry client errors (4xx) + if (error.response.status >= 400 && error.response.status < 500) { + throw new TriliumError( + error.response.data?.message || error.message, + error.response.status, + error.response.data?.code, + error.response.data + ); + } + } else if (error.request) { + // No response received + if (originalRequest._retryCount < this.config.retryAttempts) { + originalRequest._retryCount++; + + if (this.config.enableLogging) { + console.log(`[Trilium] Retry attempt ${originalRequest._retryCount}/${this.config.retryAttempts}`); + } + + // Wait before retry + await this.sleep(this.config.retryDelay * originalRequest._retryCount); + + return this.client(originalRequest); + } + + throw new TriliumConnectionError('No response from server', error.request); + } + + throw new TriliumError(error.message); + } + ); + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // Note operations + async createNote(params: CreateNoteParams): Promise<{ note: Note; branch: Branch }> { + const response = await this.client.post<{ note: Note; branch: Branch }>('/create-note', params); + return response.data; + } + + async getNote(noteId: string): Promise { + const response = await this.client.get(`/notes/${noteId}`); + return response.data; + } + + async updateNote(noteId: string, updates: Partial): Promise { + const response = await this.client.patch(`/notes/${noteId}`, updates); + return response.data; + } + + async deleteNote(noteId: string): Promise { + await this.client.delete(`/notes/${noteId}`); + } + + async getNoteContent(noteId: string): Promise { + const response = await this.client.get(`/notes/${noteId}/content`, { + responseType: 'text' + }); + return response.data; + } + + async updateNoteContent(noteId: string, content: string): Promise { + await this.client.put(`/notes/${noteId}/content`, content, { + headers: { 'Content-Type': 'text/plain' } + }); + } + + // Search + async searchNotes(params: SearchParams): Promise { + const response = await this.client.get('/notes', { params }); + return response.data; + } + + // Attributes + async createAttribute(attribute: Omit): Promise { + const response = await this.client.post('/attributes', attribute); + return response.data; + } + + async updateAttribute(attributeId: string, updates: Partial): Promise { + const response = await this.client.patch(`/attributes/${attributeId}`, updates); + return response.data; + } + + async deleteAttribute(attributeId: string): Promise { + await this.client.delete(`/attributes/${attributeId}`); + } + + // Branches + async createBranch(branch: Omit): Promise { + const response = await this.client.post('/branches', branch); + return response.data; + } + + async updateBranch(branchId: string, updates: Partial): Promise { + const response = await this.client.patch(`/branches/${branchId}`, updates); + return response.data; + } + + async deleteBranch(branchId: string): Promise { + await this.client.delete(`/branches/${branchId}`); + } + + // Attachments + async createAttachment(attachment: { + ownerId: string; + role: string; + mime: string; + title: string; + content: string; + position?: number; + }): Promise { + const response = await this.client.post('/attachments', attachment); + return response.data; + } + + async getAttachment(attachmentId: string): Promise { + const response = await this.client.get(`/attachments/${attachmentId}`); + return response.data; + } + + async getAttachmentContent(attachmentId: string): Promise { + const response = await this.client.get(`/attachments/${attachmentId}/content`, { + responseType: 'arraybuffer' + }); + return response.data; + } + + async deleteAttachment(attachmentId: string): Promise { + await this.client.delete(`/attachments/${attachmentId}`); + } + + // Special notes + async getInboxNote(date: string): Promise { + const response = await this.client.get(`/inbox/${date}`); + return response.data; + } + + async getDayNote(date: string): Promise { + const response = await this.client.get(`/calendar/days/${date}`); + return response.data; + } + + async getWeekNote(date: string): Promise { + const response = await this.client.get(`/calendar/weeks/${date}`); + return response.data; + } + + async getMonthNote(month: string): Promise { + const response = await this.client.get(`/calendar/months/${month}`); + return response.data; + } + + async getYearNote(year: string): Promise { + const response = await this.client.get(`/calendar/years/${year}`); + return response.data; + } + + // Utility + async getAppInfo(): Promise { + const response = await this.client.get('/app-info'); + return response.data; + } + + async createBackup(backupName: string): Promise { + await this.client.put(`/backup/${backupName}`); + } + + async exportNotes(noteId: string, format: 'html' | 'markdown' = 'html'): Promise { + const response = await this.client.get(`/notes/${noteId}/export`, { + params: { format }, + responseType: 'arraybuffer' + }); + return response.data; + } +} + +// Helper functions +export function createClient(baseUrl: string, token: string, options?: Partial): TriliumClient { + return new TriliumClient({ + baseUrl, + token, + ...options + }); +} + +// Batch operations helper +export class TriliumBatchClient extends TriliumClient { + async createMultipleNotes(notes: CreateNoteParams[]): Promise> { + const results = []; + + for (const noteParams of notes) { + try { + const result = await this.createNote(noteParams); + results.push(result); + } catch (error) { + if (this.config.enableLogging) { + console.error(`Failed to create note "${noteParams.title}":`, error); + } + throw error; + } + } + + return results; + } + + async searchAndUpdate( + searchQuery: string, + updateFn: (note: Note) => Partial | null + ): Promise { + const searchResults = await this.searchNotes({ search: searchQuery }); + const updatedNotes = []; + + for (const note of searchResults.results) { + const updates = updateFn(note); + if (updates) { + const updated = await this.updateNote(note.noteId, updates); + updatedNotes.push(updated); + } + } + + return updatedNotes; + } +} + +// Usage example +async function example() { + const client = createClient('http://localhost:8080/etapi', 'your-token', { + enableLogging: true, + retryAttempts: 5 + }); + + try { + // Create a note + const { note } = await client.createNote({ + parentNoteId: 'root', + title: 'Test Note', + type: 'text', + content: '

Hello, Trilium!

' + }); + + console.log('Created note:', note.noteId); + + // Search for notes + const searchResults = await client.searchNotes({ + search: '#todo', + limit: 10, + orderBy: 'dateModified', + orderDirection: 'desc' + }); + + console.log(`Found ${searchResults.results.length} todo notes`); + + // Add a label + await client.createAttribute({ + noteId: note.noteId, + type: 'label', + name: 'priority', + value: 'high' + }); + + } catch (error) { + if (error instanceof TriliumAuthError) { + console.error('Authentication failed:', error.message); + } else if (error instanceof TriliumConnectionError) { + console.error('Connection error:', error.message); + } else if (error instanceof TriliumError) { + console.error(`API error (${error.statusCode}):`, error.message); + } else { + console.error('Unexpected error:', error); + } + } +} +``` + +### Browser-Compatible JavaScript Client + +```javascript +// trilium-browser-client.js + +class TriliumBrowserClient { + constructor(baseUrl, token) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.token = token; + this.headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + }; + } + + async request(endpoint, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + const config = { + headers: { ...this.headers, ...options.headers }, + ...options + }; + + try { + const response = await fetch(url, config); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`); + } + + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json(); + } + + return response.text(); + } catch (error) { + console.error(`Request failed: ${endpoint}`, error); + throw error; + } + } + + // Notes + async createNote(parentNoteId, title, content, type = 'text') { + return this.request('/create-note', { + method: 'POST', + body: JSON.stringify({ + parentNoteId, + title, + type, + content + }) + }); + } + + async getNote(noteId) { + return this.request(`/notes/${noteId}`); + } + + async updateNote(noteId, updates) { + return this.request(`/notes/${noteId}`, { + method: 'PATCH', + body: JSON.stringify(updates) + }); + } + + async deleteNote(noteId) { + return this.request(`/notes/${noteId}`, { + method: 'DELETE' + }); + } + + async getNoteContent(noteId) { + return this.request(`/notes/${noteId}/content`); + } + + async updateNoteContent(noteId, content) { + return this.request(`/notes/${noteId}/content`, { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: content + }); + } + + // Search + async searchNotes(query, options = {}) { + const params = new URLSearchParams({ + search: query, + ...options + }); + + return this.request(`/notes?${params}`); + } + + // Attributes + async addLabel(noteId, name, value = '') { + return this.request('/attributes', { + method: 'POST', + body: JSON.stringify({ + noteId, + type: 'label', + name, + value + }) + }); + } + + async addRelation(noteId, name, targetNoteId) { + return this.request('/attributes', { + method: 'POST', + body: JSON.stringify({ + noteId, + type: 'relation', + name, + value: targetNoteId + }) + }); + } + + // Special notes + async getTodayNote() { + const today = new Date().toISOString().split('T')[0]; + return this.request(`/calendar/days/${today}`); + } + + async getInbox() { + const today = new Date().toISOString().split('T')[0]; + return this.request(`/inbox/${today}`); + } +} + +// Usage in browser +const trilium = new TriliumBrowserClient('http://localhost:8080/etapi', 'your-token'); + +// Create a quick note +async function createQuickNote(title, content) { + try { + const inbox = await trilium.getInbox(); + const result = await trilium.createNote(inbox.noteId, title, content); + console.log('Note created:', result.note.noteId); + return result; + } catch (error) { + console.error('Failed to create note:', error); + } +} +``` + +## Python Client - trilium-py + +### Installation + +```sh +pip install trilium-py +``` + +### Complete Python Implementation + +```python +# trilium_client.py + +import requests +from typing import Optional, Dict, List, Any, Union +from datetime import datetime, date +from dataclasses import dataclass, asdict +from enum import Enum +import time +import logging +from urllib.parse import urljoin +import json +import base64 + +# Set up logging +logger = logging.getLogger(__name__) + +# Enums +class NoteType(Enum): + TEXT = "text" + CODE = "code" + FILE = "file" + IMAGE = "image" + SEARCH = "search" + BOOK = "book" + RELATION_MAP = "relationMap" + RENDER = "render" + +class AttributeType(Enum): + LABEL = "label" + RELATION = "relation" + +# Data classes +@dataclass +class Note: + noteId: str + title: str + type: str + mime: Optional[str] = None + isProtected: bool = False + dateCreated: Optional[str] = None + dateModified: Optional[str] = None + utcDateCreated: Optional[str] = None + utcDateModified: Optional[str] = None + parentNoteIds: Optional[List[str]] = None + childNoteIds: Optional[List[str]] = None + attributes: Optional[List[Dict]] = None + +@dataclass +class CreateNoteRequest: + parentNoteId: str + title: str + type: str + content: str + notePosition: Optional[int] = None + prefix: Optional[str] = None + isExpanded: Optional[bool] = None + noteId: Optional[str] = None + branchId: Optional[str] = None + +@dataclass +class Attribute: + noteId: str + type: str + name: str + value: str = "" + position: Optional[int] = None + isInheritable: bool = False + attributeId: Optional[str] = None + +@dataclass +class Branch: + noteId: str + parentNoteId: str + prefix: Optional[str] = None + notePosition: Optional[int] = None + isExpanded: Optional[bool] = None + branchId: Optional[str] = None + +# Exceptions +class TriliumError(Exception): + """Base exception for Trilium API errors""" + def __init__(self, message: str, status_code: Optional[int] = None, details: Optional[Dict] = None): + super().__init__(message) + self.status_code = status_code + self.details = details + +class TriliumAuthError(TriliumError): + """Authentication error""" + pass + +class TriliumNotFoundError(TriliumError): + """Resource not found error""" + pass + +class TriliumConnectionError(TriliumError): + """Connection error""" + pass + +# Main client class +class TriliumClient: + """Python client for Trilium ETAPI""" + + def __init__( + self, + base_url: str, + token: str, + timeout: int = 30, + retry_attempts: int = 3, + retry_delay: float = 1.0, + verify_ssl: bool = True, + debug: bool = False + ): + self.base_url = base_url.rstrip('/') + self.token = token + self.timeout = timeout + self.retry_attempts = retry_attempts + self.retry_delay = retry_delay + self.verify_ssl = verify_ssl + self.debug = debug + + # Set up session + self.session = requests.Session() + self.session.headers.update({ + 'Authorization': token, + 'Content-Type': 'application/json' + }) + + # Configure logging + if debug: + logging.basicConfig(level=logging.DEBUG) + + def _request( + self, + method: str, + endpoint: str, + json_data: Optional[Dict] = None, + params: Optional[Dict] = None, + data: Optional[Union[str, bytes]] = None, + headers: Optional[Dict] = None, + **kwargs + ) -> Any: + """Make HTTP request with retry logic""" + url = urljoin(self.base_url, endpoint) + + # Merge headers + req_headers = self.session.headers.copy() + if headers: + req_headers.update(headers) + + # Retry logic + last_exception = None + for attempt in range(self.retry_attempts): + try: + if self.debug: + logger.debug(f"[Attempt {attempt + 1}] {method} {url}") + + response = self.session.request( + method=method, + url=url, + json=json_data, + params=params, + data=data, + headers=req_headers, + timeout=self.timeout, + verify=self.verify_ssl, + **kwargs + ) + + # Handle different status codes + if response.status_code == 401: + raise TriliumAuthError("Authentication failed", 401) + elif response.status_code == 404: + raise TriliumNotFoundError("Resource not found", 404) + elif response.status_code >= 500: + # Server error - retry + if attempt < self.retry_attempts - 1: + time.sleep(self.retry_delay * (attempt + 1)) + continue + else: + response.raise_for_status() + elif not response.ok: + error_data = {} + try: + error_data = response.json() + except: + pass + raise TriliumError( + error_data.get('message', f"HTTP {response.status_code}"), + response.status_code, + error_data + ) + + # Parse response + if response.status_code == 204: + return None + + content_type = response.headers.get('content-type', '') + if 'application/json' in content_type: + return response.json() + elif 'text' in content_type: + return response.text + else: + return response.content + + except requests.exceptions.ConnectionError as e: + last_exception = e + if attempt < self.retry_attempts - 1: + logger.warning(f"Connection error, retrying in {self.retry_delay * (attempt + 1)}s...") + time.sleep(self.retry_delay * (attempt + 1)) + else: + raise TriliumConnectionError(f"Connection failed after {self.retry_attempts} attempts") from e + except requests.exceptions.Timeout as e: + last_exception = e + if attempt < self.retry_attempts - 1: + logger.warning(f"Request timeout, retrying...") + time.sleep(self.retry_delay * (attempt + 1)) + else: + raise TriliumConnectionError("Request timeout") from e + except TriliumError: + raise + except Exception as e: + raise TriliumError(f"Unexpected error: {str(e)}") from e + + if last_exception: + raise TriliumConnectionError(f"Request failed after {self.retry_attempts} attempts") from last_exception + + # Note operations + def create_note( + self, + parent_note_id: str, + title: str, + content: str, + note_type: Union[str, NoteType] = NoteType.TEXT, + **kwargs + ) -> Dict[str, Any]: + """Create a new note""" + if isinstance(note_type, NoteType): + note_type = note_type.value + + data = { + 'parentNoteId': parent_note_id, + 'title': title, + 'type': note_type, + 'content': content, + **kwargs + } + + return self._request('POST', '/create-note', json_data=data) + + def get_note(self, note_id: str) -> Note: + """Get note by ID""" + data = self._request('GET', f'/notes/{note_id}') + return Note(**data) + + def update_note(self, note_id: str, **updates) -> Note: + """Update note properties""" + data = self._request('PATCH', f'/notes/{note_id}', json_data=updates) + return Note(**data) + + def delete_note(self, note_id: str) -> None: + """Delete a note""" + self._request('DELETE', f'/notes/{note_id}') + + def get_note_content(self, note_id: str) -> str: + """Get note content""" + return self._request('GET', f'/notes/{note_id}/content') + + def update_note_content(self, note_id: str, content: str) -> None: + """Update note content""" + self._request( + 'PUT', + f'/notes/{note_id}/content', + data=content, + headers={'Content-Type': 'text/plain'} + ) + + # Search + def search_notes( + self, + query: str, + fast_search: bool = False, + include_archived: bool = False, + ancestor_note_id: Optional[str] = None, + order_by: Optional[str] = None, + order_direction: str = 'asc', + limit: Optional[int] = None, + debug: bool = False + ) -> List[Note]: + """Search for notes""" + params = { + 'search': query, + 'fastSearch': fast_search, + 'includeArchivedNotes': include_archived + } + + if ancestor_note_id: + params['ancestorNoteId'] = ancestor_note_id + if order_by: + params['orderBy'] = order_by + params['orderDirection'] = order_direction + if limit: + params['limit'] = limit + if debug: + params['debug'] = debug + + data = self._request('GET', '/notes', params=params) + return [Note(**note) for note in data.get('results', [])] + + # Attributes + def add_label( + self, + note_id: str, + name: str, + value: str = "", + inheritable: bool = False, + position: Optional[int] = None + ) -> Attribute: + """Add a label to a note""" + data = { + 'noteId': note_id, + 'type': 'label', + 'name': name, + 'value': value, + 'isInheritable': inheritable + } + + if position is not None: + data['position'] = position + + result = self._request('POST', '/attributes', json_data=data) + return Attribute(**result) + + def add_relation( + self, + note_id: str, + name: str, + target_note_id: str, + inheritable: bool = False, + position: Optional[int] = None + ) -> Attribute: + """Add a relation to a note""" + data = { + 'noteId': note_id, + 'type': 'relation', + 'name': name, + 'value': target_note_id, + 'isInheritable': inheritable + } + + if position is not None: + data['position'] = position + + result = self._request('POST', '/attributes', json_data=data) + return Attribute(**result) + + def update_attribute(self, attribute_id: str, **updates) -> Attribute: + """Update an attribute""" + result = self._request('PATCH', f'/attributes/{attribute_id}', json_data=updates) + return Attribute(**result) + + def delete_attribute(self, attribute_id: str) -> None: + """Delete an attribute""" + self._request('DELETE', f'/attributes/{attribute_id}') + + # Branches + def clone_note( + self, + note_id: str, + parent_note_id: str, + prefix: Optional[str] = None, + note_position: Optional[int] = None + ) -> Branch: + """Clone a note to another location""" + data = { + 'noteId': note_id, + 'parentNoteId': parent_note_id + } + + if prefix: + data['prefix'] = prefix + if note_position is not None: + data['notePosition'] = note_position + + result = self._request('POST', '/branches', json_data=data) + return Branch(**result) + + def update_branch(self, branch_id: str, **updates) -> Branch: + """Update a branch""" + result = self._request('PATCH', f'/branches/{branch_id}', json_data=updates) + return Branch(**result) + + def delete_branch(self, branch_id: str) -> None: + """Delete a branch""" + self._request('DELETE', f'/branches/{branch_id}') + + # Attachments + def upload_attachment( + self, + note_id: str, + file_path: str, + title: Optional[str] = None, + mime: Optional[str] = None, + position: Optional[int] = None + ) -> Dict[str, Any]: + """Upload a file as attachment""" + import mimetypes + import os + + if title is None: + title = os.path.basename(file_path) + + if mime is None: + mime = mimetypes.guess_type(file_path)[0] or 'application/octet-stream' + + with open(file_path, 'rb') as f: + content = base64.b64encode(f.read()).decode('utf-8') + + data = { + 'ownerId': note_id, + 'role': 'file', + 'mime': mime, + 'title': title, + 'content': content + } + + if position is not None: + data['position'] = position + + return self._request('POST', '/attachments', json_data=data) + + def download_attachment(self, attachment_id: str, output_path: str) -> str: + """Download an attachment""" + content = self._request('GET', f'/attachments/{attachment_id}/content') + + with open(output_path, 'wb') as f: + if isinstance(content, bytes): + f.write(content) + else: + f.write(content.encode('utf-8')) + + return output_path + + # Special notes + def get_inbox(self, target_date: Optional[Union[str, date]] = None) -> Note: + """Get inbox note for a date""" + if target_date is None: + target_date = date.today() + elif isinstance(target_date, date): + target_date = target_date.strftime('%Y-%m-%d') + + data = self._request('GET', f'/inbox/{target_date}') + return Note(**data) + + def get_day_note(self, target_date: Optional[Union[str, date]] = None) -> Note: + """Get day note for a date""" + if target_date is None: + target_date = date.today() + elif isinstance(target_date, date): + target_date = target_date.strftime('%Y-%m-%d') + + data = self._request('GET', f'/calendar/days/{target_date}') + return Note(**data) + + def get_week_note(self, target_date: Optional[Union[str, date]] = None) -> Note: + """Get week note for a date""" + if target_date is None: + target_date = date.today() + elif isinstance(target_date, date): + target_date = target_date.strftime('%Y-%m-%d') + + data = self._request('GET', f'/calendar/weeks/{target_date}') + return Note(**data) + + def get_month_note(self, month: Optional[str] = None) -> Note: + """Get month note""" + if month is None: + month = date.today().strftime('%Y-%m') + + data = self._request('GET', f'/calendar/months/{month}') + return Note(**data) + + def get_year_note(self, year: Optional[Union[str, int]] = None) -> Note: + """Get year note""" + if year is None: + year = str(date.today().year) + elif isinstance(year, int): + year = str(year) + + data = self._request('GET', f'/calendar/years/{year}') + return Note(**data) + + # Utility + def get_app_info(self) -> Dict[str, Any]: + """Get application information""" + return self._request('GET', '/app-info') + + def create_backup(self, backup_name: str) -> None: + """Create a backup""" + self._request('PUT', f'/backup/{backup_name}') + + def export_notes( + self, + note_id: str, + output_file: str, + format: str = 'html' + ) -> str: + """Export notes to ZIP file""" + content = self._request( + 'GET', + f'/notes/{note_id}/export', + params={'format': format} + ) + + with open(output_file, 'wb') as f: + f.write(content) + + return output_file + + def create_note_revision(self, note_id: str) -> None: + """Create a revision for a note""" + self._request('POST', f'/notes/{note_id}/revision') + + def refresh_note_ordering(self, parent_note_id: str) -> None: + """Refresh note ordering""" + self._request('POST', f'/refresh-note-ordering/{parent_note_id}') + +# Helper class for batch operations +class TriliumBatchClient(TriliumClient): + """Extended client with batch operations""" + + def create_notes_batch( + self, + notes: List[CreateNoteRequest], + delay: float = 0.1 + ) -> List[Dict[str, Any]]: + """Create multiple notes with delay between requests""" + results = [] + + for note_req in notes: + result = self.create_note(**asdict(note_req)) + results.append(result) + time.sleep(delay) + + return results + + def add_labels_batch( + self, + note_id: str, + labels: Dict[str, str] + ) -> List[Attribute]: + """Add multiple labels to a note""" + results = [] + + for name, value in labels.items(): + attr = self.add_label(note_id, name, value) + results.append(attr) + + return results + + def search_and_tag( + self, + search_query: str, + tag_name: str, + tag_value: str = "" + ) -> List[str]: + """Search for notes and add a tag to all results""" + notes = self.search_notes(search_query) + tagged = [] + + for note in notes: + self.add_label(note.noteId, tag_name, tag_value) + tagged.append(note.noteId) + + return tagged + +# Context manager for automatic connection handling +class TriliumContext: + """Context manager for Trilium client""" + + def __init__(self, base_url: str, token: str, **kwargs): + self.base_url = base_url + self.token = token + self.kwargs = kwargs + self.client = None + + def __enter__(self) -> TriliumClient: + self.client = TriliumClient(self.base_url, self.token, **self.kwargs) + return self.client + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.client and hasattr(self.client, 'session'): + self.client.session.close() + +# Usage examples +if __name__ == "__main__": + # Basic usage + client = TriliumClient( + base_url="http://localhost:8080/etapi", + token="your-token", + debug=True + ) + + # Create a note + result = client.create_note( + parent_note_id="root", + title="Test Note", + content="

This is a test note

", + note_type=NoteType.TEXT + ) + print(f"Created note: {result['note']['noteId']}") + + # Search notes + todo_notes = client.search_notes("#todo", limit=10) + for note in todo_notes: + print(f"- {note.title}") + + # Using context manager + with TriliumContext("http://localhost:8080/etapi", "your-token") as api: + inbox = api.get_inbox() + print(f"Inbox note ID: {inbox.noteId}") + + # Batch operations + batch_client = TriliumBatchClient( + base_url="http://localhost:8080/etapi", + token="your-token" + ) + + # Tag all notes matching a search + tagged = batch_client.search_and_tag( + search_query="type:text", + tag_name="processed", + tag_value=datetime.now().isoformat() + ) + print(f"Tagged {len(tagged)} notes") +``` + +## Go Client + +```go +// trilium_client.go + +package trilium + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Note represents a Trilium note +type Note struct { + NoteID string `json:"noteId"` + Title string `json:"title"` + Type string `json:"type"` + Mime string `json:"mime,omitempty"` + IsProtected bool `json:"isProtected"` + DateCreated string `json:"dateCreated,omitempty"` + DateModified string `json:"dateModified,omitempty"` + UTCDateCreated string `json:"utcDateCreated,omitempty"` + UTCDateModified string `json:"utcDateModified,omitempty"` + Attributes []Attribute `json:"attributes,omitempty"` + ParentNoteIDs []string `json:"parentNoteIds,omitempty"` + ChildNoteIDs []string `json:"childNoteIds,omitempty"` +} + +// CreateNoteRequest represents a request to create a note +type CreateNoteRequest struct { + ParentNoteID string `json:"parentNoteId"` + Title string `json:"title"` + Type string `json:"type"` + Content string `json:"content"` + NotePosition int `json:"notePosition,omitempty"` + Prefix string `json:"prefix,omitempty"` + IsExpanded bool `json:"isExpanded,omitempty"` +} + +// Attribute represents a note attribute +type Attribute struct { + AttributeID string `json:"attributeId,omitempty"` + NoteID string `json:"noteId"` + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + Position int `json:"position,omitempty"` + IsInheritable bool `json:"isInheritable,omitempty"` +} + +// Branch represents a note branch +type Branch struct { + BranchID string `json:"branchId,omitempty"` + NoteID string `json:"noteId"` + ParentNoteID string `json:"parentNoteId"` + Prefix string `json:"prefix,omitempty"` + NotePosition int `json:"notePosition,omitempty"` + IsExpanded bool `json:"isExpanded,omitempty"` +} + +// SearchParams represents search parameters +type SearchParams struct { + Search string `url:"search"` + FastSearch bool `url:"fastSearch,omitempty"` + IncludeArchivedNotes bool `url:"includeArchivedNotes,omitempty"` + AncestorNoteID string `url:"ancestorNoteId,omitempty"` + AncestorDepth string `url:"ancestorDepth,omitempty"` + OrderBy string `url:"orderBy,omitempty"` + OrderDirection string `url:"orderDirection,omitempty"` + Limit int `url:"limit,omitempty"` + Debug bool `url:"debug,omitempty"` +} + +// SearchResponse represents search results +type SearchResponse struct { + Results []Note `json:"results"` + DebugInfo map[string]interface{} `json:"debugInfo,omitempty"` +} + +// Client is the Trilium API client +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// NewClient creates a new Trilium client +func NewClient(baseURL, token string) *Client { + return &Client{ + BaseURL: baseURL, + Token: token, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// request makes an HTTP request to the API +func (c *Client) request(method, endpoint string, body interface{}) (*http.Response, error) { + url := c.BaseURL + endpoint + + var reqBody io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonBody) + } + + req, err := http.NewRequest(method, url, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", c.Token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + if resp.StatusCode >= 400 { + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(bodyBytes)) + } + + return resp, nil +} + +// CreateNote creates a new note +func (c *Client) CreateNote(req CreateNoteRequest) (*Note, *Branch, error) { + resp, err := c.request("POST", "/create-note", req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + var result struct { + Note Note `json:"note"` + Branch Branch `json:"branch"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result.Note, &result.Branch, nil +} + +// GetNote retrieves a note by ID +func (c *Client) GetNote(noteID string) (*Note, error) { + resp, err := c.request("GET", fmt.Sprintf("/notes/%s", noteID), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var note Note + if err := json.NewDecoder(resp.Body).Decode(¬e); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return ¬e, nil +} + +// UpdateNote updates a note +func (c *Client) UpdateNote(noteID string, updates map[string]interface{}) (*Note, error) { + resp, err := c.request("PATCH", fmt.Sprintf("/notes/%s", noteID), updates) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var note Note + if err := json.NewDecoder(resp.Body).Decode(¬e); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return ¬e, nil +} + +// DeleteNote deletes a note +func (c *Client) DeleteNote(noteID string) error { + resp, err := c.request("DELETE", fmt.Sprintf("/notes/%s", noteID), nil) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// GetNoteContent retrieves note content +func (c *Client) GetNoteContent(noteID string) (string, error) { + resp, err := c.request("GET", fmt.Sprintf("/notes/%s/content", noteID), nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + + content, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + return string(content), nil +} + +// UpdateNoteContent updates note content +func (c *Client) UpdateNoteContent(noteID, content string) error { + req, err := http.NewRequest("PUT", c.BaseURL+fmt.Sprintf("/notes/%s/content", noteID), bytes.NewBufferString(content)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", c.Token) + req.Header.Set("Content-Type", "text/plain") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error %d: %s", resp.StatusCode, string(bodyBytes)) + } + + return nil +} + +// SearchNotes searches for notes +func (c *Client) SearchNotes(params SearchParams) (*SearchResponse, error) { + query := url.Values{} + query.Set("search", params.Search) + + if params.FastSearch { + query.Set("fastSearch", "true") + } + if params.IncludeArchivedNotes { + query.Set("includeArchivedNotes", "true") + } + if params.AncestorNoteID != "" { + query.Set("ancestorNoteId", params.AncestorNoteID) + } + if params.OrderBy != "" { + query.Set("orderBy", params.OrderBy) + } + if params.OrderDirection != "" { + query.Set("orderDirection", params.OrderDirection) + } + if params.Limit > 0 { + query.Set("limit", fmt.Sprintf("%d", params.Limit)) + } + + resp, err := c.request("GET", fmt.Sprintf("/notes?%s", query.Encode()), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var searchResp SearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &searchResp, nil +} + +// AddLabel adds a label to a note +func (c *Client) AddLabel(noteID, name, value string) (*Attribute, error) { + attr := Attribute{ + NoteID: noteID, + Type: "label", + Name: name, + Value: value, + } + + resp, err := c.request("POST", "/attributes", attr) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result Attribute + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// Usage example +func Example() { + client := NewClient("http://localhost:8080/etapi", "your-token") + + // Create a note + note, branch, err := client.CreateNote(CreateNoteRequest{ + ParentNoteID: "root", + Title: "Test Note", + Type: "text", + Content: "

Hello from Go!

", + }) + + if err != nil { + panic(err) + } + + fmt.Printf("Created note %s with branch %s\n", note.NoteID, branch.BranchID) + + // Search notes + results, err := client.SearchNotes(SearchParams{ + Search: "#todo", + Limit: 10, + }) + + if err != nil { + panic(err) + } + + fmt.Printf("Found %d todo notes\n", len(results.Results)) +} +``` + +## REST Client Best Practices + +### 1\. Connection Management + +```python +# Python - Connection pooling with requests +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +class RobustTriliumClient: + def __init__(self, base_url, token): + self.base_url = base_url + self.token = token + + # Configure connection pooling and retries + self.session = requests.Session() + + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE", "POST"] + ) + + adapter = HTTPAdapter( + max_retries=retry_strategy, + pool_connections=10, + pool_maxsize=10 + ) + + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + + self.session.headers.update({ + 'Authorization': token, + 'Content-Type': 'application/json' + }) +``` + +### 2\. Request Timeout Handling + +```javascript +// JavaScript - Timeout with abort controller +class TimeoutClient { + constructor(baseUrl, token, timeout = 30000) { + this.baseUrl = baseUrl; + this.token = token; + this.timeout = timeout; + } + + async request(endpoint, options = {}) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + headers: { + 'Authorization': this.token, + ...options.headers + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return response.json(); + } catch (error) { + if (error.name === 'AbortError') { + throw new Error(`Request timeout after ${this.timeout}ms`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } +} +``` + +### 3\. Rate Limiting + +```python +# Python - Rate limiting with token bucket +import time +from threading import Lock + +class RateLimitedClient: + def __init__(self, base_url, token, requests_per_second=10): + self.base_url = base_url + self.token = token + self.rate_limit = requests_per_second + self.tokens = requests_per_second + self.last_update = time.time() + self.lock = Lock() + + def _wait_for_token(self): + with self.lock: + now = time.time() + elapsed = now - self.last_update + self.tokens = min( + self.rate_limit, + self.tokens + elapsed * self.rate_limit + ) + self.last_update = now + + if self.tokens < 1: + sleep_time = (1 - self.tokens) / self.rate_limit + time.sleep(sleep_time) + self.tokens = 1 + + self.tokens -= 1 + + def request(self, method, endpoint, **kwargs): + self._wait_for_token() + # Make actual request here + return self._make_request(method, endpoint, **kwargs) +``` + +### 4\. Caching + +```typescript +// TypeScript - Response caching +interface CacheEntry { + data: T; + timestamp: number; + ttl: number; +} + +class CachedTriliumClient extends TriliumClient { + private cache = new Map>(); + private defaultTTL = 5 * 60 * 1000; // 5 minutes + + private getCacheKey(method: string, endpoint: string, params?: any): string { + return `${method}:${endpoint}:${JSON.stringify(params || {})}`; + } + + private isExpired(entry: CacheEntry): boolean { + return Date.now() - entry.timestamp > entry.ttl; + } + + async cachedRequest( + method: string, + endpoint: string, + options: { + params?: any; + ttl?: number; + forceRefresh?: boolean; + } = {} + ): Promise { + const key = this.getCacheKey(method, endpoint, options.params); + + // Check cache for GET requests + if (method === 'GET' && !options.forceRefresh) { + const cached = this.cache.get(key); + if (cached && !this.isExpired(cached)) { + return cached.data; + } + } + + // Make request + const data = await this.request(endpoint, { + method, + params: options.params + }); + + // Cache GET responses + if (method === 'GET') { + this.cache.set(key, { + data, + timestamp: Date.now(), + ttl: options.ttl || this.defaultTTL + }); + } + + return data; + } + + clearCache(pattern?: string): void { + if (pattern) { + for (const key of this.cache.keys()) { + if (key.includes(pattern)) { + this.cache.delete(key); + } + } + } else { + this.cache.clear(); + } + } +} +``` + +## Error Handling Patterns + +### Comprehensive Error Handling + +```python +# Python - Detailed error handling +class TriliumAPIError(Exception): + """Base exception for API errors""" + def __init__(self, message, status_code=None, response_data=None): + super().__init__(message) + self.status_code = status_code + self.response_data = response_data + +class TriliumValidationError(TriliumAPIError): + """Validation error (400)""" + pass + +class TriliumAuthenticationError(TriliumAPIError): + """Authentication error (401)""" + pass + +class TriliumPermissionError(TriliumAPIError): + """Permission error (403)""" + pass + +class TriliumNotFoundError(TriliumAPIError): + """Resource not found (404)""" + pass + +class TriliumRateLimitError(TriliumAPIError): + """Rate limit exceeded (429)""" + pass + +class TriliumServerError(TriliumAPIError): + """Server error (5xx)""" + pass + +def handle_api_error(response): + """Handle API error responses""" + try: + error_data = response.json() + message = error_data.get('message', response.reason) + except: + message = response.reason + error_data = None + + status_code = response.status_code + + if status_code == 400: + raise TriliumValidationError(message, status_code, error_data) + elif status_code == 401: + raise TriliumAuthenticationError(message, status_code, error_data) + elif status_code == 403: + raise TriliumPermissionError(message, status_code, error_data) + elif status_code == 404: + raise TriliumNotFoundError(message, status_code, error_data) + elif status_code == 429: + raise TriliumRateLimitError(message, status_code, error_data) + elif status_code >= 500: + raise TriliumServerError(message, status_code, error_data) + else: + raise TriliumAPIError(message, status_code, error_data) + +# Usage +try: + note = client.get_note('invalid_id') +except TriliumNotFoundError as e: + print(f"Note not found: {e}") +except TriliumAuthenticationError as e: + print(f"Authentication failed: {e}") + # Refresh token or re-authenticate +except TriliumServerError as e: + print(f"Server error: {e}") + # Retry after delay +except TriliumAPIError as e: + print(f"API error ({e.status_code}): {e}") +``` + +## Retry Strategies + +### Exponential Backoff + +```javascript +// JavaScript - Exponential backoff with jitter +class RetryClient { + constructor(baseUrl, token, maxRetries = 3) { + this.baseUrl = baseUrl; + this.token = token; + this.maxRetries = maxRetries; + } + + async requestWithRetry(endpoint, options = {}, attempt = 0) { + try { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + headers: { + 'Authorization': this.token, + ...options.headers + } + }); + + if (response.status >= 500 && attempt < this.maxRetries) { + throw new Error(`Server error: ${response.status}`); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return response.json(); + + } catch (error) { + if (attempt >= this.maxRetries) { + throw error; + } + + // Calculate delay with exponential backoff and jitter + const baseDelay = Math.pow(2, attempt) * 1000; + const jitter = Math.random() * 1000; + const delay = baseDelay + jitter; + + console.log(`Retry attempt ${attempt + 1} after ${delay}ms`); + + await new Promise(resolve => setTimeout(resolve, delay)); + + return this.requestWithRetry(endpoint, options, attempt + 1); + } + } +} +``` + +### Circuit Breaker Pattern + +```python +# Python - Circuit breaker implementation +import time +from enum import Enum +from threading import Lock + +class CircuitState(Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + +class CircuitBreaker: + def __init__( + self, + failure_threshold=5, + recovery_timeout=60, + expected_exception=Exception + ): + self.failure_threshold = failure_threshold + self.recovery_timeout = recovery_timeout + self.expected_exception = expected_exception + + self.failure_count = 0 + self.last_failure_time = None + self.state = CircuitState.CLOSED + self.lock = Lock() + + def call(self, func, *args, **kwargs): + with self.lock: + if self.state == CircuitState.OPEN: + if time.time() - self.last_failure_time > self.recovery_timeout: + self.state = CircuitState.HALF_OPEN + else: + raise Exception("Circuit breaker is OPEN") + + try: + result = func(*args, **kwargs) + with self.lock: + self.on_success() + return result + except self.expected_exception as e: + with self.lock: + self.on_failure() + raise e + + def on_success(self): + self.failure_count = 0 + self.state = CircuitState.CLOSED + + def on_failure(self): + self.failure_count += 1 + self.last_failure_time = time.time() + + if self.failure_count >= self.failure_threshold: + self.state = CircuitState.OPEN + +class CircuitBreakerClient(TriliumClient): + def __init__(self, base_url, token): + super().__init__(base_url, token) + self.circuit_breaker = CircuitBreaker( + failure_threshold=5, + recovery_timeout=60, + expected_exception=TriliumConnectionError + ) + + def _request(self, method, endpoint, **kwargs): + return self.circuit_breaker.call( + super()._request, + method, + endpoint, + **kwargs + ) +``` + +## Testing Client Libraries + +### Unit Testing + +```python +# Python - Unit tests with mocking +import unittest +from unittest.mock import Mock, patch, MagicMock +import json + +class TestTriliumClient(unittest.TestCase): + def setUp(self): + self.client = TriliumClient( + base_url="http://localhost:8080/etapi", + token="test-token" + ) + + @patch('requests.Session.request') + def test_create_note(self, mock_request): + # Mock response + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = { + 'note': { + 'noteId': 'test123', + 'title': 'Test Note', + 'type': 'text' + }, + 'branch': { + 'branchId': 'branch123', + 'noteId': 'test123', + 'parentNoteId': 'root' + } + } + mock_request.return_value = mock_response + + # Test create note + result = self.client.create_note( + parent_note_id='root', + title='Test Note', + content='

Test content

' + ) + + # Assertions + self.assertEqual(result['note']['noteId'], 'test123') + self.assertEqual(result['note']['title'], 'Test Note') + + # Verify request was made correctly + mock_request.assert_called_once() + call_args = mock_request.call_args + self.assertEqual(call_args[1]['method'], 'POST') + self.assertEqual(call_args[1]['url'], 'http://localhost:8080/etapi/create-note') + + @patch('requests.Session.request') + def test_search_notes(self, mock_request): + # Mock response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'results': [ + {'noteId': 'note1', 'title': 'Note 1'}, + {'noteId': 'note2', 'title': 'Note 2'} + ] + } + mock_request.return_value = mock_response + + # Test search + results = self.client.search_notes('#todo', limit=10) + + # Assertions + self.assertEqual(len(results), 2) + self.assertEqual(results[0].noteId, 'note1') + + @patch('requests.Session.request') + def test_error_handling(self, mock_request): + # Mock error response + mock_response = Mock() + mock_response.status_code = 404 + mock_response.json.return_value = { + 'status': 404, + 'code': 'NOTE_NOT_FOUND', + 'message': 'Note not found' + } + mock_request.return_value = mock_response + + # Test error handling + with self.assertRaises(TriliumNotFoundError) as context: + self.client.get_note('invalid_id') + + self.assertEqual(context.exception.status_code, 404) + self.assertIn('Note not found', str(context.exception)) + +class TestRetryLogic(unittest.TestCase): + @patch('time.sleep') + @patch('requests.Session.request') + def test_retry_on_server_error(self, mock_request, mock_sleep): + client = TriliumClient( + base_url="http://localhost:8080/etapi", + token="test-token", + retry_attempts=3 + ) + + # Mock server error then success + mock_response_error = Mock() + mock_response_error.status_code = 500 + + mock_response_success = Mock() + mock_response_success.status_code = 200 + mock_response_success.json.return_value = {'noteId': 'test123'} + + mock_request.side_effect = [ + mock_response_error, + mock_response_error, + mock_response_success + ] + + # Should succeed after retries + result = client.get_note('test123') + self.assertEqual(result.noteId, 'test123') + + # Verify retries happened + self.assertEqual(mock_request.call_count, 3) + self.assertEqual(mock_sleep.call_count, 2) + +if __name__ == '__main__': + unittest.main() +``` + +### Integration Testing + +```javascript +// JavaScript - Integration tests with Jest +describe('TriliumClient Integration Tests', () => { + let client; + let testNoteId; + + beforeAll(() => { + client = new TriliumClient({ + baseUrl: process.env.TRILIUM_URL || 'http://localhost:8080/etapi', + token: process.env.TRILIUM_TOKEN || 'test-token' + }); + }); + + afterAll(async () => { + // Cleanup test notes + if (testNoteId) { + await client.deleteNote(testNoteId); + } + }); + + test('should create and retrieve a note', async () => { + // Create note + const createResult = await client.createNote({ + parentNoteId: 'root', + title: 'Integration Test Note', + type: 'text', + content: '

Test content

' + }); + + expect(createResult.note).toBeDefined(); + expect(createResult.note.title).toBe('Integration Test Note'); + + testNoteId = createResult.note.noteId; + + // Retrieve note + const note = await client.getNote(testNoteId); + expect(note.noteId).toBe(testNoteId); + expect(note.title).toBe('Integration Test Note'); + }); + + test('should update note content', async () => { + const newContent = '

Updated content

'; + + await client.updateNoteContent(testNoteId, newContent); + + const content = await client.getNoteContent(testNoteId); + expect(content).toBe(newContent); + }); + + test('should add and retrieve attributes', async () => { + // Add label + const attribute = await client.createAttribute({ + noteId: testNoteId, + type: 'label', + name: 'testLabel', + value: 'testValue' + }); + + expect(attribute.attributeId).toBeDefined(); + + // Retrieve note with attributes + const note = await client.getNote(testNoteId); + const label = note.attributes.find(a => a.name === 'testLabel'); + + expect(label).toBeDefined(); + expect(label.value).toBe('testValue'); + }); + + test('should search notes', async () => { + // Add searchable label + await client.createAttribute({ + noteId: testNoteId, + type: 'label', + name: 'integrationTest', + value: '' + }); + + // Search + const results = await client.searchNotes({ + search: '#integrationTest', + limit: 10 + }); + + expect(results.results).toBeDefined(); + expect(results.results.length).toBeGreaterThan(0); + + const foundNote = results.results.find(n => n.noteId === testNoteId); + expect(foundNote).toBeDefined(); + }); +}); +``` + +## Conclusion + +These client libraries provide robust, production-ready implementations for interacting with the Trilium API. Key considerations: + +1. **Choose the right language** for your use case and environment +2. **Implement proper error handling** with specific exception types +3. **Use connection pooling** for better performance +4. **Add retry logic** for resilience against transient failures +5. **Consider rate limiting** to avoid overwhelming the server +6. **Cache responses** when appropriate to reduce API calls +7. **Write comprehensive tests** for reliability +8. **Document your client** with clear examples + +For additional resources: + +* [ETAPI Complete Guide](ETAPI%20Complete%20Guide.md) +* [WebSocket API Documentation](WebSocket%20API.md) +* [Script API Cookbook](Script%20API%20Cookbook.md) \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/API Documentation/ETAPI Complete Guide.md b/docs/Developer Guide/Developer Guide/API Documentation/ETAPI Complete Guide.md new file mode 100644 index 0000000000..8e9d694a70 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/ETAPI Complete Guide.md @@ -0,0 +1,1798 @@ +# ETAPI Complete Guide +## ETAPI Guide + +ETAPI (External Trilium API) is the REST API for integrating external applications with Trilium Notes. This guide walks you through authentication, common operations, and best practices for building integrations. + +## Getting Started + +ETAPI provides programmatic access to your notes, attributes, and attachments through a RESTful interface. The API is designed to be predictable and easy to use, with comprehensive error messages to help you debug issues quickly. + +Base URL: `http://localhost:8080/etapi` + +## Authentication + +Before using the API, you need to authenticate your requests. There are three ways to authenticate with ETAPI. + +### Token Authentication (Recommended) + +First, generate an ETAPI token in Trilium by navigating to Options → ETAPI and clicking "Create new ETAPI token". Then include this token in your request headers: + +```sh +curl -H "Authorization: your-token" http://localhost:8080/etapi/notes/root +``` + +In Python: + +```python +import requests + +headers = {'Authorization': 'your-token'} +response = requests.get('http://localhost:8080/etapi/notes/root', headers=headers) +``` + +### Basic Authentication + +You can also use the token as a password with any username: + +```sh +curl -u "user:your-token" http://localhost:8080/etapi/notes/root +``` + +### Password Authentication + +For automated scripts, you can login with your Trilium password to receive a session token. This method is useful when you cannot store API tokens securely: + +```python +# Login and get session token +response = requests.post('http://localhost:8080/etapi/auth/login', + json={'password': 'your-password'}) +token = response.json()['authToken'] + +# Use the session token +headers = {'Authorization': token} +``` + +## API Endpoints + +### Notes + +#### Create Note + +**POST** `/etapi/create-note` + +Creating a note requires just a parent note ID and title. The content and other properties are optional: + +```json +{ + "parentNoteId": "root", + "title": "My New Note", + "type": "text", + "content": "

Note content in HTML

" +} +``` + +The response includes both the created note and its branch (position in the tree): + +```python +def create_note(parent_id, title, content=""): + response = requests.post( + "http://localhost:8080/etapi/create-note", + headers={'Authorization': 'your-token'}, + json={ + "parentNoteId": parent_id, + "title": title, + "content": content + } + ) + return response.json() + +# Create a simple note +note = create_note("root", "Meeting Notes") +print(f"Created: {note['note']['noteId']}") +``` + +#### Get Note by ID + +**GET** `/etapi/notes/{noteId}` + +**cURL Example:** + +```sh +curl -X GET http://localhost:8080/etapi/notes/evnnmvHTCgIn \ + -H "Authorization: your-token" +``` + +**Response:** + +```json +{ + "noteId": "evnnmvHTCgIn", + "title": "My Note", + "type": "text", + "mime": "text/html", + "isProtected": false, + "attributes": [ + { + "attributeId": "abc123", + "noteId": "evnnmvHTCgIn", + "type": "label", + "name": "todo", + "value": "", + "position": 10, + "isInheritable": false + } + ], + "parentNoteIds": ["root"], + "childNoteIds": ["child1", "child2"], + "dateCreated": "2024-01-15 10:30:00.000+0100", + "dateModified": "2024-01-15 14:20:00.000+0100", + "utcDateCreated": "2024-01-15 09:30:00.000Z", + "utcDateModified": "2024-01-15 13:20:00.000Z" +} +``` + +#### Update Note + +**PATCH** `/etapi/notes/{noteId}` + +**Request Body:** + +```json +{ + "title": "Updated Title", + "type": "text", + "mime": "text/html" +} +``` + +**JavaScript Example:** + +```javascript +async function updateNote(noteId, updates) { + const response = await fetch(`http://localhost:8080/etapi/notes/${noteId}`, { + method: 'PATCH', + headers: { + 'Authorization': 'your-token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updates) + }); + + if (!response.ok) { + throw new Error(`Failed to update note: ${response.statusText}`); + } + + return response.json(); +} + +// Usage +updateNote('evnnmvHTCgIn', { title: 'New Title' }) + .then(note => console.log('Updated note:', note)) + .catch(err => console.error('Error:', err)); +``` + +#### Delete Note + +**DELETE** `/etapi/notes/{noteId}` + +```sh +curl -X DELETE http://localhost:8080/etapi/notes/evnnmvHTCgIn \ + -H "Authorization: your-token" +``` + +#### Get Note Content + +**GET** `/etapi/notes/{noteId}/content` + +Returns the content of a note. + +```python +import requests + +def get_note_content(note_id, token): + url = f"http://localhost:8080/etapi/notes/{note_id}/content" + headers = {'Authorization': token} + + response = requests.get(url, headers=headers) + return response.text # Returns HTML or text content + +content = get_note_content('evnnmvHTCgIn', 'your-token') +print(content) +``` + +#### Update Note Content + +**PUT** `/etapi/notes/{noteId}/content` + +```python +def update_note_content(note_id, content, token): + url = f"http://localhost:8080/etapi/notes/{note_id}/content" + headers = { + 'Authorization': token, + 'Content-Type': 'text/plain' + } + + response = requests.put(url, headers=headers, data=content) + return response.status_code == 204 + +# Update with HTML content +html_content = "

Updated Content

New paragraph

" +success = update_note_content('evnnmvHTCgIn', html_content, 'your-token') +``` + +### Search + +#### Search Notes + +**GET** `/etapi/notes` + +Search notes using Trilium's powerful search syntax. The search parameter accepts keywords, labels, and complex expressions. + +```python +# Simple keyword search +results = requests.get( + "http://localhost:8080/etapi/notes", + headers={'Authorization': 'token'}, + params={'search': 'project management'} +).json() + +# Search by label +results = requests.get( + "http://localhost:8080/etapi/notes", + params={'search': '#todo'} +).json() + +# Complex search with sorting +results = requests.get( + "http://localhost:8080/etapi/notes", + params={ + 'search': 'type:text #important', + 'orderBy': 'dateModified', + 'orderDirection': 'desc', + 'limit': 10 + } +).json() +``` + +Common search patterns: + +* Keywords: `project management` +* Labels: `#todo`, `#priority=high` +* Note type: `type:text`, `type:code` +* Date ranges: `dateCreated>=2024-01-01` +* Subtree search: Use `ancestorNoteId` parameter + +### Attributes + +Attributes are key-value metadata attached to notes. There are two types: labels (name-value pairs) and relations (links to other notes). + +#### Create Attribute + +**POST** `/etapi/attributes` + +```python +# Add a simple label +requests.post( + "http://localhost:8080/etapi/attributes", + headers={'Authorization': 'token'}, + json={ + "noteId": "note123", + "type": "label", + "name": "todo" + } +) + +# Add label with value +requests.post( + "http://localhost:8080/etapi/attributes", + json={ + "noteId": "note123", + "type": "label", + "name": "priority", + "value": "high" + } +) +``` + +#### Update Attribute + +**PATCH** `/etapi/attributes/{attributeId}` + +You can only update the value and position of labels. Relations can only have their position updated. + +```python +# Update attribute value +requests.patch( + "http://localhost:8080/etapi/attributes/attr123", + json={"value": "low"} +) +``` + +#### Delete Attribute + +**DELETE** `/etapi/attributes/{attributeId}` + +### Branches + +Branches represent the position of notes in the tree structure. A note can appear in multiple locations through cloning. + +#### Clone Note to Another Location + +**POST** `/etapi/branches` + +```python +# Clone a note to a new parent +requests.post( + "http://localhost:8080/etapi/branches", + headers={'Authorization': 'token'}, + json={ + "noteId": "note123", + "parentNoteId": "newParent456" + } +) +``` + +This creates a "clone" - the same note appearing in multiple places. Changes to the note content affect all locations. + +#### Move Note Position + +**PATCH** `/etapi/branches/{branchId}` + +```python +# Change note position or prefix +requests.patch( + "http://localhost:8080/etapi/branches/branch123", + json={"notePosition": 5} +) +``` + +#### Remove Branch + +**DELETE** `/etapi/branches/{branchId}` + +Removes a note from one location. If it's the last location, the note itself is deleted. + +### Attachments + +#### Create Attachment + +**POST** `/etapi/attachments` + +```json +{ + "ownerId": "evnnmvHTCgIn", + "role": "file", + "mime": "application/pdf", + "title": "document.pdf", + "content": "base64-encoded-content", + "position": 10 +} +``` + +**Python Example with File Upload:** + +```python +import base64 + +def upload_attachment(note_id, file_path, title=None): + with open(file_path, 'rb') as f: + content = base64.b64encode(f.read()).decode('utf-8') + + import mimetypes + mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream' + + if title is None: + import os + title = os.path.basename(file_path) + + url = "http://localhost:8080/etapi/attachments" + headers = { + 'Authorization': 'your-token', + 'Content-Type': 'application/json' + } + + data = { + "ownerId": note_id, + "role": "file", + "mime": mime_type, + "title": title, + "content": content + } + + response = requests.post(url, headers=headers, json=data) + return response.json() + +# Upload a PDF +attachment = upload_attachment("evnnmvHTCgIn", "/path/to/document.pdf") +print(f"Attachment ID: {attachment['attachmentId']}") +``` + +#### Get Attachment Content + +**GET** `/etapi/attachments/{attachmentId}/content` + +```python +def download_attachment(attachment_id, output_path): + url = f"http://localhost:8080/etapi/attachments/{attachment_id}/content" + headers = {'Authorization': 'your-token'} + + response = requests.get(url, headers=headers) + + with open(output_path, 'wb') as f: + f.write(response.content) + + return output_path + +# Download attachment +download_attachment("attachId123", "/tmp/downloaded.pdf") +``` + +### Special Notes + +#### Get Inbox Note + +**GET** `/etapi/inbox/{date}` + +Gets or creates an inbox note for the specified date. + +```python +from datetime import date + +def get_inbox_note(target_date=None): + if target_date is None: + target_date = date.today() + + date_str = target_date.strftime('%Y-%m-%d') + url = f"http://localhost:8080/etapi/inbox/{date_str}" + headers = {'Authorization': 'your-token'} + + response = requests.get(url, headers=headers) + return response.json() + +# Get today's inbox +inbox = get_inbox_note() +print(f"Inbox note ID: {inbox['noteId']}") +``` + +#### Calendar Notes + +**Day Note:** + +```python +def get_day_note(date_str): + url = f"http://localhost:8080/etapi/calendar/days/{date_str}" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +day_note = get_day_note("2024-01-15") +``` + +**Week Note:** + +```python +def get_week_note(date_str): + url = f"http://localhost:8080/etapi/calendar/weeks/{date_str}" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +week_note = get_week_note("2024-01-15") +``` + +**Month Note:** + +```python +def get_month_note(month_str): + url = f"http://localhost:8080/etapi/calendar/months/{month_str}" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +month_note = get_month_note("2024-01") +``` + +**Year Note:** + +```python +def get_year_note(year): + url = f"http://localhost:8080/etapi/calendar/years/{year}" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +year_note = get_year_note("2024") +``` + +### Import/Export + +#### Export Note Subtree + +**GET** `/etapi/notes/{noteId}/export` + +Exports a note subtree as a ZIP file. + +**Query Parameters:** + +* `format`: "html" (default) or "markdown" + +```python +def export_subtree(note_id, output_file, format="html"): + url = f"http://localhost:8080/etapi/notes/{note_id}/export" + headers = {'Authorization': 'your-token'} + params = {'format': format} + + response = requests.get(url, headers=headers, params=params) + + with open(output_file, 'wb') as f: + f.write(response.content) + + return output_file + +# Export entire database +export_subtree("root", "backup.zip") + +# Export specific subtree as markdown +export_subtree("projectNoteId", "project.zip", format="markdown") +``` + +#### Import ZIP + +**POST** `/etapi/notes/{noteId}/import` + +Imports a ZIP file into a note. + +```python +def import_zip(parent_note_id, zip_file_path): + url = f"http://localhost:8080/etapi/notes/{parent_note_id}/import" + headers = {'Authorization': 'your-token'} + + with open(zip_file_path, 'rb') as f: + files = {'file': f} + response = requests.post(url, headers=headers, files=files) + + return response.json() + +# Import backup +imported = import_zip("root", "backup.zip") +print(f"Imported note ID: {imported['note']['noteId']}") +``` + +### Utility Endpoints + +#### Create Note Revision + +**POST** `/etapi/notes/{noteId}/revision` + +Forces creation of a revision for the specified note. + +```sh +curl -X POST http://localhost:8080/etapi/notes/evnnmvHTCgIn/revision \ + -H "Authorization: your-token" +``` + +#### Refresh Note Ordering + +**POST** `/etapi/refresh-note-ordering/{parentNoteId}` + +Updates note ordering in connected clients after changing positions. + +```python +def reorder_children(parent_id, note_positions): + """ + note_positions: dict of {noteId: position} + """ + headers = { + 'Authorization': 'your-token', + 'Content-Type': 'application/json' + } + + # Update each branch position + for note_id, position in note_positions.items(): + # Get the branch ID first + note = requests.get( + f"http://localhost:8080/etapi/notes/{note_id}", + headers=headers + ).json() + + for branch_id in note['parentBranchIds']: + branch = requests.get( + f"http://localhost:8080/etapi/branches/{branch_id}", + headers=headers + ).json() + + if branch['parentNoteId'] == parent_id: + # Update position + requests.patch( + f"http://localhost:8080/etapi/branches/{branch_id}", + headers=headers, + json={'notePosition': position} + ) + + # Refresh ordering + requests.post( + f"http://localhost:8080/etapi/refresh-note-ordering/{parent_id}", + headers=headers + ) + +# Reorder notes +reorder_children("parentId", { + "note1": 10, + "note2": 20, + "note3": 30 +}) +``` + +#### Get App Info + +**GET** `/etapi/app-info` + +Returns information about the Trilium instance. + +```python +def get_app_info(): + url = "http://localhost:8080/etapi/app-info" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +info = get_app_info() +print(f"Trilium version: {info['appVersion']}") +print(f"Database version: {info['dbVersion']}") +print(f"Data directory: {info['dataDirectory']}") +``` + +#### Create Backup + +**PUT** `/etapi/backup/{backupName}` + +Creates a database backup. + +```sh +curl -X PUT http://localhost:8080/etapi/backup/daily \ + -H "Authorization: your-token" +``` + +This creates a backup file named `backup-daily.db` in the data directory. + +## Common Use Cases + +### 1\. Daily Journal Entry + +```python +from datetime import date +import requests + +class TriliumJournal: + def __init__(self, base_url, token): + self.base_url = base_url + self.headers = {'Authorization': token} + + def create_journal_entry(self, content, tags=[]): + # Get today's day note + today = date.today().strftime('%Y-%m-%d') + day_note_url = f"{self.base_url}/calendar/days/{today}" + day_note = requests.get(day_note_url, headers=self.headers).json() + + # Create entry + entry_data = { + "parentNoteId": day_note['noteId'], + "title": f"Entry - {date.today().strftime('%H:%M')}", + "type": "text", + "content": content + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=entry_data + ) + + entry = response.json() + + # Add tags + for tag in tags: + self.add_tag(entry['note']['noteId'], tag) + + return entry + + def add_tag(self, note_id, tag_name): + attr_data = { + "noteId": note_id, + "type": "label", + "name": tag_name, + "value": "" + } + + requests.post( + f"{self.base_url}/attributes", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=attr_data + ) + +# Usage +journal = TriliumJournal("http://localhost:8080/etapi", "your-token") +entry = journal.create_journal_entry( + "

Today's meeting went well. Key decisions:

  • Item 1
", + tags=["meeting", "important"] +) +``` + +### 2\. Task Management System + +```python +class TriliumTaskManager: + def __init__(self, base_url, token): + self.base_url = base_url + self.headers = {'Authorization': token} + self.task_parent_id = self.get_or_create_task_root() + + def get_or_create_task_root(self): + # Search for existing task root + search_url = f"{self.base_url}/notes" + params = {'search': '#taskRoot'} + response = requests.get(search_url, headers=self.headers, params=params) + results = response.json()['results'] + + if results: + return results[0]['noteId'] + + # Create task root + data = { + "parentNoteId": "root", + "title": "Tasks", + "type": "text", + "content": "

Task Management System

" + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + + note_id = response.json()['note']['noteId'] + + # Add taskRoot label + self.add_label(note_id, "taskRoot") + return note_id + + def create_task(self, title, description, priority="medium", due_date=None): + data = { + "parentNoteId": self.task_parent_id, + "title": title, + "type": "text", + "content": f"

{description}

" + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + + task = response.json() + task_id = task['note']['noteId'] + + # Add task attributes + self.add_label(task_id, "task") + self.add_label(task_id, "todoStatus", "todo") + self.add_label(task_id, "priority", priority) + + if due_date: + self.add_label(task_id, "dueDate", due_date) + + return task + + def get_tasks(self, status=None): + if status: + search = f"#task #todoStatus={status}" + else: + search = "#task" + + params = { + 'search': search, + 'ancestorNoteId': self.task_parent_id + } + + response = requests.get( + f"{self.base_url}/notes", + headers=self.headers, + params=params + ) + + return response.json()['results'] + + def complete_task(self, task_id): + # Find the todoStatus attribute + note = requests.get( + f"{self.base_url}/notes/{task_id}", + headers=self.headers + ).json() + + for attr in note['attributes']: + if attr['name'] == 'todoStatus': + # Update status + requests.patch( + f"{self.base_url}/attributes/{attr['attributeId']}", + headers={**self.headers, 'Content-Type': 'application/json'}, + json={'value': 'done'} + ) + break + + def add_label(self, note_id, name, value=""): + data = { + "noteId": note_id, + "type": "label", + "name": name, + "value": value + } + + requests.post( + f"{self.base_url}/attributes", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + +# Usage +tasks = TriliumTaskManager("http://localhost:8080/etapi", "your-token") + +# Create tasks +task1 = tasks.create_task( + "Review API documentation", + "Check for completeness and accuracy", + priority="high", + due_date="2024-01-20" +) + +task2 = tasks.create_task( + "Update client library", + "Add new ETAPI endpoints", + priority="medium" +) + +# List pending tasks +pending = tasks.get_tasks(status="todo") +for task in pending: + print(f"- {task['title']}") + +# Complete a task +tasks.complete_task(task1['note']['noteId']) +``` + +### 3\. Knowledge Base Builder + +```python +class KnowledgeBase: + def __init__(self, base_url, token): + self.base_url = base_url + self.headers = {'Authorization': token} + + def create_article(self, category, title, content, tags=[]): + # Find or create category + category_id = self.get_or_create_category(category) + + # Create article + data = { + "parentNoteId": category_id, + "title": title, + "type": "text", + "content": content + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + + article = response.json() + article_id = article['note']['noteId'] + + # Add tags + for tag in tags: + self.add_label(article_id, tag) + + # Add article label + self.add_label(article_id, "article") + + return article + + def get_or_create_category(self, name): + # Search for existing category + params = {'search': f'#category #categoryName={name}'} + response = requests.get( + f"{self.base_url}/notes", + headers=self.headers, + params=params + ) + + results = response.json()['results'] + if results: + return results[0]['noteId'] + + # Create new category + data = { + "parentNoteId": "root", + "title": name, + "type": "text", + "content": f"

{name}

" + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + + category_id = response.json()['note']['noteId'] + + self.add_label(category_id, "category") + self.add_label(category_id, "categoryName", name) + + return category_id + + def search_articles(self, query): + params = { + 'search': f'#article {query}', + 'orderBy': 'relevancy' + } + + response = requests.get( + f"{self.base_url}/notes", + headers=self.headers, + params=params + ) + + return response.json()['results'] + + def add_label(self, note_id, name, value=""): + data = { + "noteId": note_id, + "type": "label", + "name": name, + "value": value + } + + requests.post( + f"{self.base_url}/attributes", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + +# Usage +kb = KnowledgeBase("http://localhost:8080/etapi", "your-token") + +# Add articles +article = kb.create_article( + category="Python", + title="Working with REST APIs", + content=""" +

Introduction

+

REST APIs are fundamental to modern web development...

+

Best Practices

+
    +
  • Use proper HTTP methods
  • +
  • Handle errors gracefully
  • +
  • Implement retry logic
  • +
+ """, + tags=["api", "rest", "tutorial"] +) + +# Search articles +results = kb.search_articles("REST API") +for article in results: + print(f"Found: {article['title']}") +``` + +## Client Library Examples + +### JavaScript/TypeScript Client + +```typescript +class TriliumClient { + private baseUrl: string; + private token: string; + + constructor(baseUrl: string, token: string) { + this.baseUrl = baseUrl; + this.token = token; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + const headers = { + 'Authorization': this.token, + 'Content-Type': 'application/json', + ...options.headers + }; + + const response = await fetch(url, { + ...options, + headers + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`API Error: ${error.message}`); + } + + if (response.status === 204) { + return null; + } + + return response.json(); + } + + async getNote(noteId: string) { + return this.request(`/notes/${noteId}`); + } + + async createNote(data: any) { + return this.request('/create-note', { + method: 'POST', + body: JSON.stringify(data) + }); + } + + async updateNote(noteId: string, updates: any) { + return this.request(`/notes/${noteId}`, { + method: 'PATCH', + body: JSON.stringify(updates) + }); + } + + async deleteNote(noteId: string) { + return this.request(`/notes/${noteId}`, { + method: 'DELETE' + }); + } + + async searchNotes(query: string, options: any = {}) { + const params = new URLSearchParams({ + search: query, + ...options + }); + + return this.request(`/notes?${params}`); + } + + async addAttribute(noteId: string, type: string, name: string, value = '') { + return this.request('/attributes', { + method: 'POST', + body: JSON.stringify({ + noteId, + type, + name, + value + }) + }); + } +} + +// Usage +const client = new TriliumClient('http://localhost:8080/etapi', 'your-token'); + +// Create a note +const note = await client.createNote({ + parentNoteId: 'root', + title: 'New Note', + type: 'text', + content: '

Content

' +}); + +// Search notes +const results = await client.searchNotes('#todo', { + orderBy: 'dateModified', + orderDirection: 'desc', + limit: 10 +}); + +// Add a label +await client.addAttribute(note.note.noteId, 'label', 'important'); +``` + +### Python Client Class + +```python +import requests +from typing import Optional, Dict, List, Any +from datetime import datetime +import json + +class TriliumETAPI: + """Python client for Trilium ETAPI""" + + def __init__(self, base_url: str, token: str): + self.base_url = base_url.rstrip('/') + self.session = requests.Session() + self.session.headers.update({ + 'Authorization': token, + 'Content-Type': 'application/json' + }) + + def _request(self, method: str, endpoint: str, **kwargs) -> Any: + """Make API request with error handling""" + url = f"{self.base_url}{endpoint}" + + try: + response = self.session.request(method, url, **kwargs) + response.raise_for_status() + + if response.status_code == 204: + return None + + return response.json() if response.content else None + + except requests.exceptions.HTTPError as e: + if response.text: + try: + error = response.json() + raise Exception(f"API Error {error.get('code')}: {error.get('message')}") + except json.JSONDecodeError: + raise Exception(f"HTTP {response.status_code}: {response.text}") + raise e + + # Note operations + def create_note( + self, + parent_note_id: str, + title: str, + content: str, + note_type: str = "text", + **kwargs + ) -> Dict: + """Create a new note""" + data = { + "parentNoteId": parent_note_id, + "title": title, + "type": note_type, + "content": content, + **kwargs + } + return self._request('POST', '/create-note', json=data) + + def get_note(self, note_id: str) -> Dict: + """Get note by ID""" + return self._request('GET', f'/notes/{note_id}') + + def update_note(self, note_id: str, updates: Dict) -> Dict: + """Update note properties""" + return self._request('PATCH', f'/notes/{note_id}', json=updates) + + def delete_note(self, note_id: str) -> None: + """Delete a note""" + self._request('DELETE', f'/notes/{note_id}') + + def get_note_content(self, note_id: str) -> str: + """Get note content""" + response = self.session.get(f"{self.base_url}/notes/{note_id}/content") + response.raise_for_status() + return response.text + + def update_note_content(self, note_id: str, content: str) -> None: + """Update note content""" + headers = {'Content-Type': 'text/plain'} + self.session.put( + f"{self.base_url}/notes/{note_id}/content", + data=content, + headers=headers + ).raise_for_status() + + # Search + def search_notes( + self, + query: str, + fast_search: bool = False, + include_archived: bool = False, + ancestor_note_id: Optional[str] = None, + order_by: Optional[str] = None, + order_direction: str = "asc", + limit: Optional[int] = None + ) -> List[Dict]: + """Search for notes""" + params = { + 'search': query, + 'fastSearch': fast_search, + 'includeArchivedNotes': include_archived + } + + if ancestor_note_id: + params['ancestorNoteId'] = ancestor_note_id + if order_by: + params['orderBy'] = order_by + params['orderDirection'] = order_direction + if limit: + params['limit'] = limit + + result = self._request('GET', '/notes', params=params) + return result.get('results', []) + + # Attributes + def add_label( + self, + note_id: str, + name: str, + value: str = "", + inheritable: bool = False + ) -> Dict: + """Add a label to a note""" + data = { + "noteId": note_id, + "type": "label", + "name": name, + "value": value, + "isInheritable": inheritable + } + return self._request('POST', '/attributes', json=data) + + def add_relation( + self, + note_id: str, + name: str, + target_note_id: str, + inheritable: bool = False + ) -> Dict: + """Add a relation to a note""" + data = { + "noteId": note_id, + "type": "relation", + "name": name, + "value": target_note_id, + "isInheritable": inheritable + } + return self._request('POST', '/attributes', json=data) + + def update_attribute(self, attribute_id: str, updates: Dict) -> Dict: + """Update an attribute""" + return self._request('PATCH', f'/attributes/{attribute_id}', json=updates) + + def delete_attribute(self, attribute_id: str) -> None: + """Delete an attribute""" + self._request('DELETE', f'/attributes/{attribute_id}') + + # Branches + def clone_note( + self, + note_id: str, + parent_note_id: str, + prefix: str = "" + ) -> Dict: + """Clone a note to another location""" + data = { + "noteId": note_id, + "parentNoteId": parent_note_id, + "prefix": prefix + } + return self._request('POST', '/branches', json=data) + + def move_note( + self, + note_id: str, + new_parent_id: str + ) -> None: + """Move a note to a new parent""" + # Get current branches + note = self.get_note(note_id) + + # Delete old branches + for branch_id in note['parentBranchIds']: + self._request('DELETE', f'/branches/{branch_id}') + + # Create new branch + self.clone_note(note_id, new_parent_id) + + # Special notes + def get_inbox(self, date: Optional[datetime] = None) -> Dict: + """Get inbox note for a date""" + if date is None: + date = datetime.now() + date_str = date.strftime('%Y-%m-%d') + return self._request('GET', f'/inbox/{date_str}') + + def get_day_note(self, date: Optional[datetime] = None) -> Dict: + """Get day note for a date""" + if date is None: + date = datetime.now() + date_str = date.strftime('%Y-%m-%d') + return self._request('GET', f'/calendar/days/{date_str}') + + # Utility + def get_app_info(self) -> Dict: + """Get application information""" + return self._request('GET', '/app-info') + + def create_backup(self, name: str) -> None: + """Create a backup""" + self._request('PUT', f'/backup/{name}') + + def export_subtree( + self, + note_id: str, + format: str = "html" + ) -> bytes: + """Export note subtree as ZIP""" + params = {'format': format} + response = self.session.get( + f"{self.base_url}/notes/{note_id}/export", + params=params + ) + response.raise_for_status() + return response.content + +# Example usage +if __name__ == "__main__": + # Initialize client + api = TriliumETAPI("http://localhost:8080/etapi", "your-token") + + # Create a note + note = api.create_note( + parent_note_id="root", + title="API Test Note", + content="

Created via Python client

" + ) + print(f"Created note: {note['note']['noteId']}") + + # Add labels + api.add_label(note['note']['noteId'], "test") + api.add_label(note['note']['noteId'], "priority", "high") + + # Search + results = api.search_notes("#test", limit=10) + for result in results: + print(f"Found: {result['title']}") + + # Export backup + backup_data = api.export_subtree("root") + with open("backup.zip", "wb") as f: + f.write(backup_data) +``` + +## Rate Limiting and Best Practices + +### Rate Limiting + +ETAPI implements rate limiting for authentication endpoints: + +* **Login endpoint**: Maximum 10 requests per IP per hour +* **Other endpoints**: No specific rate limits, but excessive requests may be throttled + +### Best Practices + +#### 1\. Connection Pooling + +Reuse HTTP connections for better performance: + +```python +import requests +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry + +session = requests.Session() +retry = Retry( + total=3, + backoff_factor=0.3, + status_forcelist=[500, 502, 503, 504] +) +adapter = HTTPAdapter(max_retries=retry) +session.mount('http://', adapter) +session.mount('https://', adapter) +``` + +#### 2\. Batch Operations + +When possible, batch multiple operations: + +```python +def batch_create_notes(notes_data): + """Create multiple notes efficiently""" + created_notes = [] + + for data in notes_data: + note = api.create_note(**data) + created_notes.append(note) + + # Add small delay to avoid overwhelming server + time.sleep(0.1) + + return created_notes +``` + +#### 3\. Error Handling + +Implement robust error handling: + +```python +import time +from typing import Callable, Any + +def retry_on_error( + func: Callable, + max_retries: int = 3, + backoff_factor: float = 1.0 +) -> Any: + """Retry function with exponential backoff""" + for attempt in range(max_retries): + try: + return func() + except requests.exceptions.RequestException as e: + if attempt == max_retries - 1: + raise + + wait_time = backoff_factor * (2 ** attempt) + print(f"Request failed, retrying in {wait_time}s...") + time.sleep(wait_time) + +# Usage +note = retry_on_error( + lambda: api.create_note("root", "Title", "Content") +) +``` + +#### 4\. Caching + +Cache frequently accessed data: + +```python +from functools import lru_cache +from datetime import datetime, timedelta + +class CachedTriliumClient(TriliumETAPI): + def __init__(self, base_url: str, token: str): + super().__init__(base_url, token) + self._cache = {} + self._cache_times = {} + + def get_note_cached(self, note_id: str, max_age: int = 300): + """Get note with caching (max_age in seconds)""" + cache_key = f"note:{note_id}" + + if cache_key in self._cache: + cache_time = self._cache_times[cache_key] + if datetime.now() - cache_time < timedelta(seconds=max_age): + return self._cache[cache_key] + + note = self.get_note(note_id) + self._cache[cache_key] = note + self._cache_times[cache_key] = datetime.now() + + return note +``` + +#### 5\. Pagination for Large Results + +Handle large result sets with pagination: + +```python +def search_all_notes(api: TriliumETAPI, query: str, batch_size: int = 100): + """Search with pagination for large result sets""" + all_results = [] + offset = 0 + + while True: + results = api.search_notes( + query, + limit=batch_size, + order_by="dateCreated" + ) + + if not results: + break + + all_results.extend(results) + + if len(results) < batch_size: + break + + # Use the last note's date as reference for next batch + last_date = results[-1]['dateCreated'] + query_with_date = f"{query} dateCreated>{last_date}" + + return all_results +``` + +## Migration from Internal API + +### Key Differences + +| Aspect | Internal API | ETAPI | +| --- | --- | --- | +| **Purpose** | Trilium client communication | External integrations | +| **Authentication** | Session-based | Token-based | +| **Stability** | May change between versions | Stable interface | +| **CSRF Protection** | Required | Not required | +| **WebSocket** | Supported | Not available | +| **Documentation** | Limited | Comprehensive | + +### Migration Steps + +1. **Replace Authentication** + + ```python + # Old (Internal API) + session = requests.Session() + session.post('/api/login', data={'password': 'pass'}) + + # New (ETAPI) + headers = {'Authorization': 'etapi-token'} + ``` +2. **Update Endpoints** + + ```python + # Old + /api/notes/getNoteById/noteId + + # New + /etapi/notes/noteId + ``` +3. **Adjust Request/Response Format** + + ```python + # Old (may vary) + response = session.post('/api/notes/new', json={ + 'parentNoteId': 'root', + 'title': 'Title' + }) + + # New (standardized) + response = requests.post('/etapi/create-note', + headers=headers, + json={ + 'parentNoteId': 'root', + 'title': 'Title', + 'type': 'text', + 'content': '' + } + ) + ``` + +## Error Handling + +### Common Error Codes + +| Status | Code | Description | Resolution | +| --- | --- | --- | --- | +| 400 | BAD\_REQUEST | Invalid request format | Check request body and parameters | +| 401 | UNAUTHORIZED | Invalid or missing token | Verify authentication token | +| 404 | NOTE\_NOT\_FOUND | Note doesn't exist | Check note ID | +| 404 | BRANCH\_NOT\_FOUND | Branch doesn't exist | Verify branch ID | +| 400 | NOTE\_IS\_PROTECTED | Cannot modify protected note | Unlock protected session first | +| 429 | TOO\_MANY\_REQUESTS | Rate limit exceeded | Wait before retrying | +| 500 | INTERNAL\_ERROR | Server error | Report issue, check logs | + +### Error Response Format + +```json +{ + "status": 400, + "code": "VALIDATION_ERROR", + "message": "Note title cannot be empty" +} +``` + +### Handling Errors in Code + +```python +class ETAPIError(Exception): + def __init__(self, status, code, message): + self.status = status + self.code = code + self.message = message + super().__init__(f"{code}: {message}") + +def handle_api_response(response): + if response.status_code >= 400: + try: + error = response.json() + raise ETAPIError( + error.get('status'), + error.get('code'), + error.get('message') + ) + except json.JSONDecodeError: + raise ETAPIError( + response.status_code, + 'UNKNOWN_ERROR', + response.text + ) + + return response.json() if response.content else None + +# Usage +try: + response = requests.get( + 'http://localhost:8080/etapi/notes/invalid', + headers={'Authorization': 'token'} + ) + note = handle_api_response(response) +except ETAPIError as e: + if e.code == 'NOTE_NOT_FOUND': + print("Note doesn't exist") + else: + print(f"API Error: {e.message}") +``` + +## Performance Considerations + +### 1\. Minimize API Calls + +```python +# Bad: Multiple calls +note = api.get_note(note_id) +for child_id in note['childNoteIds']: + child = api.get_note(child_id) # N+1 problem + process(child) + +# Good: Batch processing +note = api.get_note(note_id) +children = api.search_notes( + f"note.parents.noteId={note_id}", + limit=1000 +) +for child in children: + process(child) +``` + +### 2\. Use Appropriate Search Depth + +```python +# Limit search depth for better performance +results = api.search_notes( + "keyword", + ancestor_note_id="root", + ancestor_depth="lt3" # Only search 3 levels deep +) +``` + +### 3\. Content Compression + +Enable gzip compression for large responses: + +```python +session = requests.Session() +session.headers.update({ + 'Authorization': 'token', + 'Accept-Encoding': 'gzip, deflate' +}) +``` + +### 4\. Async Operations + +Use async requests for parallel operations: + +```python +import asyncio +import aiohttp + +class AsyncTriliumClient: + def __init__(self, base_url: str, token: str): + self.base_url = base_url + self.headers = {'Authorization': token} + + async def get_note(self, session, note_id): + url = f"{self.base_url}/notes/{note_id}" + async with session.get(url, headers=self.headers) as response: + return await response.json() + + async def get_multiple_notes(self, note_ids): + async with aiohttp.ClientSession() as session: + tasks = [self.get_note(session, nid) for nid in note_ids] + return await asyncio.gather(*tasks) + +# Usage +client = AsyncTriliumClient("http://localhost:8080/etapi", "token") +notes = asyncio.run(client.get_multiple_notes(['id1', 'id2', 'id3'])) +``` + +### 5\. Database Optimization + +For bulk operations, consider: + +* Creating notes in batches +* Using transactions (via backup/restore) +* Indexing frequently searched attributes + +## Security Considerations + +### Token Management + +* Store tokens securely (environment variables, key vaults) +* Rotate tokens regularly +* Use separate tokens for different applications +* Never commit tokens to version control + +```python +import os +from dotenv import load_dotenv + +load_dotenv() + +# Load token from environment +TOKEN = os.getenv('TRILIUM_ETAPI_TOKEN') +if not TOKEN: + raise ValueError("TRILIUM_ETAPI_TOKEN not set") + +api = TriliumETAPI("http://localhost:8080/etapi", TOKEN) +``` + +### HTTPS Usage + +Always use HTTPS in production: + +```python +# Development +dev_api = TriliumETAPI("http://localhost:8080/etapi", token) + +# Production +prod_api = TriliumETAPI("https://notes.example.com/etapi", token) +``` + +### Input Validation + +Sanitize user input before sending to API: + +```python +import html +import re + +def sanitize_html(content: str) -> str: + """Basic HTML sanitization""" + # Remove script tags + content = re.sub(r']*>.*?', '', content, flags=re.DOTALL) + # Remove on* attributes + content = re.sub(r'\s*on\w+\s*=\s*["\'][^"\']*["\']', '', content) + return content + +def create_safe_note(title: str, content: str): + safe_title = html.escape(title) + safe_content = sanitize_html(content) + + return api.create_note( + parent_note_id="root", + title=safe_title, + content=safe_content + ) +``` + +## Troubleshooting + +### Connection Issues + +```python +# Test connection +def test_connection(base_url, token): + try: + api = TriliumETAPI(base_url, token) + info = api.get_app_info() + print(f"Connected to Trilium {info['appVersion']}") + return True + except Exception as e: + print(f"Connection failed: {e}") + return False + +# Debug mode +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +### Common Issues and Solutions + +| Issue | Cause | Solution | +| --- | --- | --- | +| 401 Unauthorized | Invalid token | Regenerate token in Trilium Options | +| Connection refused | Server not running | Start Trilium server | +| CORS errors | Cross-origin requests | Configure CORS in Trilium settings | +| Timeout errors | Large operations | Increase timeout, use async | +| 404 Not Found | Wrong endpoint | Check ETAPI prefix in URL | +| Protected note error | Note is encrypted | Enter protected session first | + +## Additional Resources + +* [Trilium GitHub Repository](https://github.com/TriliumNext/Trilium) +* [OpenAPI Specification](#root/euAWtBArCWdw) +* [Trilium Search Documentation](https://triliumnext.github.io/Docs/Wiki/search.html) +* [Community Forum](https://github.com/TriliumNext/Trilium/discussions) \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/API Documentation/Internal API Reference.md b/docs/Developer Guide/Developer Guide/API Documentation/Internal API Reference.md new file mode 100644 index 0000000000..a397b113d8 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/Internal API Reference.md @@ -0,0 +1,1926 @@ +# Internal API Reference +## Table of Contents + +1. [Introduction](#introduction) +2. [Authentication and Session Management](#authentication-and-session-management) +3. [Core API Endpoints](#core-api-endpoints) +4. [WebSocket Real-time Updates](#websocket-real-time-updates) +5. [File Operations](#file-operations) +6. [Import/Export Operations](#import-export-operations) +7. [Synchronization API](#synchronization-api) +8. [When to Use Internal vs ETAPI](#when-to-use-internal-vs-etapi) +9. [Security Considerations](#security-considerations) + +## Introduction + +The Internal API is the primary interface used by the Trilium Notes client application to communicate with the server. While powerful and feature-complete, this API is primarily designed for internal use. + +### Important Notice + +**For external integrations, please use [ETAPI](ETAPI%20Complete%20Guide.md) instead.** The Internal API: + +* May change between versions without notice +* Requires session-based authentication with CSRF protection +* Is tightly coupled with the frontend application +* Has limited documentation and stability guarantees + +### Base URL + +``` +http://localhost:8080/api +``` + +### Key Characteristics + +* Session-based authentication with cookies +* CSRF token protection for state-changing operations +* WebSocket support for real-time updates +* Full feature parity with the Trilium UI +* Complex request/response formats optimized for the client + +## Authentication and Session Management + +### Password Login + +**POST** `/api/login` + +Authenticates user with password and creates a session. + +**Request:** + +```javascript +const formData = new URLSearchParams(); +formData.append('password', 'your-password'); + +const response = await fetch('http://localhost:8080/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: formData, + credentials: 'include' // Important for cookie handling +}); +``` + +**Response:** + +```json +{ + "success": true, + "message": "Login successful" +} +``` + +The server sets a session cookie (`trilium.sid`) that must be included in subsequent requests. + +### TOTP Authentication (2FA) + +If 2FA is enabled, include the TOTP token: + +```javascript +formData.append('password', 'your-password'); +formData.append('totpToken', '123456'); +``` + +### Token Authentication + +**POST** `/api/login/token` + +Generate an API token for programmatic access: + +```javascript +const response = await fetch('http://localhost:8080/api/login/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + password: 'your-password', + tokenName: 'My Integration' + }) +}); + +const { authToken } = await response.json(); +// Use this token in Authorization header for future requests +``` + +### Protected Session + +**POST** `/api/login/protected` + +Enter protected session to access encrypted notes: + +```javascript +await fetch('http://localhost:8080/api/login/protected', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + password: 'your-password' + }), + credentials: 'include' +}); +``` + +### Logout + +**POST** `/api/logout` + +```javascript +await fetch('http://localhost:8080/api/logout', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +## Core API Endpoints + +### Notes + +#### Get Note + +**GET** `/api/notes/{noteId}` + +```javascript +const response = await fetch('http://localhost:8080/api/notes/root', { + credentials: 'include' +}); + +const note = await response.json(); +``` + +**Response:** + +```json +{ + "noteId": "root", + "title": "Trilium Notes", + "type": "text", + "mime": "text/html", + "isProtected": false, + "isDeleted": false, + "dateCreated": "2024-01-01 00:00:00.000+0000", + "dateModified": "2024-01-15 10:30:00.000+0000", + "utcDateCreated": "2024-01-01 00:00:00.000Z", + "utcDateModified": "2024-01-15 10:30:00.000Z", + "parentBranches": [ + { + "branchId": "root_root", + "parentNoteId": "none", + "prefix": null, + "notePosition": 10 + } + ], + "attributes": [], + "cssClass": "", + "iconClass": "bx bx-folder" +} +``` + +#### Create Note + +**POST** `/api/notes/{parentNoteId}/children` + +```javascript +const response = await fetch('http://localhost:8080/api/notes/root/children', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + title: 'New Note', + type: 'text', + content: '

Note content

', + isProtected: false + }), + credentials: 'include' +}); + +const { note, branch } = await response.json(); +``` + +#### Update Note + +**PUT** `/api/notes/{noteId}` + +```javascript +await fetch(`http://localhost:8080/api/notes/${noteId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + title: 'Updated Title', + type: 'text', + mime: 'text/html' + }), + credentials: 'include' +}); +``` + +#### Delete Note + +**DELETE** `/api/notes/{noteId}` + +```javascript +await fetch(`http://localhost:8080/api/notes/${noteId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +#### Get Note Content + +**GET** `/api/notes/{noteId}/content` + +Returns the actual content of the note: + +```javascript +const response = await fetch(`http://localhost:8080/api/notes/${noteId}/content`, { + credentials: 'include' +}); + +const content = await response.text(); +``` + +#### Save Note Content + +**PUT** `/api/notes/{noteId}/content` + +```javascript +await fetch(`http://localhost:8080/api/notes/${noteId}/content`, { + method: 'PUT', + headers: { + 'Content-Type': 'text/html', + 'X-CSRF-Token': csrfToken + }, + body: '

Updated content

', + credentials: 'include' +}); +``` + +### Tree Operations + +#### Get Branch + +**GET** `/api/branches/{branchId}` + +```javascript +const branch = await fetch(`http://localhost:8080/api/branches/${branchId}`, { + credentials: 'include' +}).then(r => r.json()); +``` + +#### Move Note + +**PUT** `/api/branches/{branchId}/move` + +```javascript +await fetch(`http://localhost:8080/api/branches/${branchId}/move`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + parentNoteId: 'newParentId', + beforeNoteId: 'siblingNoteId' // optional, for positioning + }), + credentials: 'include' +}); +``` + +#### Clone Note + +**POST** `/api/notes/{noteId}/clone` + +```javascript +const response = await fetch(`http://localhost:8080/api/notes/${noteId}/clone`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + parentNoteId: 'targetParentId', + prefix: 'Copy of ' + }), + credentials: 'include' +}); +``` + +#### Sort Child Notes + +**PUT** `/api/notes/{noteId}/sort-children` + +```javascript +await fetch(`http://localhost:8080/api/notes/${noteId}/sort-children`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + sortBy: 'title', // or 'dateCreated', 'dateModified' + reverse: false + }), + credentials: 'include' +}); +``` + +### Attributes + +#### Create Attribute + +**POST** `/api/notes/{noteId}/attributes` + +```javascript +const response = await fetch(`http://localhost:8080/api/notes/${noteId}/attributes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + type: 'label', + name: 'todo', + value: '', + isInheritable: false + }), + credentials: 'include' +}); +``` + +#### Update Attribute + +**PUT** `/api/attributes/{attributeId}` + +```javascript +await fetch(`http://localhost:8080/api/attributes/${attributeId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + value: 'updated value' + }), + credentials: 'include' +}); +``` + +#### Delete Attribute + +**DELETE** `/api/attributes/{attributeId}` + +```javascript +await fetch(`http://localhost:8080/api/attributes/${attributeId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +### Search + +#### Search Notes + +**GET** `/api/search` + +```javascript +const params = new URLSearchParams({ + query: '#todo OR #task', + fastSearch: 'false', + includeArchivedNotes: 'false', + ancestorNoteId: 'root', + orderBy: 'relevancy', + orderDirection: 'desc', + limit: '50' +}); + +const response = await fetch(`http://localhost:8080/api/search?${params}`, { + credentials: 'include' +}); + +const { results } = await response.json(); +``` + +#### Search Note Map + +**GET** `/api/search-note-map` + +Returns hierarchical structure of search results: + +```javascript +const params = new URLSearchParams({ + query: 'project', + maxDepth: '3' +}); + +const noteMap = await fetch(`http://localhost:8080/api/search-note-map?${params}`, { + credentials: 'include' +}).then(r => r.json()); +``` + +### Revisions + +#### Get Note Revisions + +**GET** `/api/notes/{noteId}/revisions` + +```javascript +const revisions = await fetch(`http://localhost:8080/api/notes/${noteId}/revisions`, { + credentials: 'include' +}).then(r => r.json()); +``` + +#### Get Revision Content + +**GET** `/api/revisions/{revisionId}/content` + +```javascript +const content = await fetch(`http://localhost:8080/api/revisions/${revisionId}/content`, { + credentials: 'include' +}).then(r => r.text()); +``` + +#### Restore Revision + +**POST** `/api/revisions/{revisionId}/restore` + +```javascript +await fetch(`http://localhost:8080/api/revisions/${revisionId}/restore`, { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +#### Delete Revision + +**DELETE** `/api/revisions/{revisionId}` + +```javascript +await fetch(`http://localhost:8080/api/revisions/${revisionId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +## WebSocket Real-time Updates + +The Internal API provides WebSocket connections for real-time synchronization and updates. + +### Connection Setup + +```javascript +class TriliumWebSocket { + constructor() { + this.ws = null; + this.reconnectInterval = 5000; + this.shouldReconnect = true; + } + + connect() { + // WebSocket URL same as base URL but with ws:// protocol + const wsUrl = 'ws://localhost:8080'; + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.sendPing(); + }; + + this.ws.onmessage = (event) => { + const message = JSON.parse(event.data); + this.handleMessage(message); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + if (this.shouldReconnect) { + setTimeout(() => this.connect(), this.reconnectInterval); + } + }; + } + + handleMessage(message) { + switch (message.type) { + case 'sync': + this.handleSync(message.data); + break; + case 'entity-changes': + this.handleEntityChanges(message.data); + break; + case 'refresh-tree': + this.refreshTree(); + break; + case 'create-note': + this.handleNoteCreated(message.data); + break; + case 'update-note': + this.handleNoteUpdated(message.data); + break; + case 'delete-note': + this.handleNoteDeleted(message.data); + break; + default: + console.log('Unknown message type:', message.type); + } + } + + sendPing() { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'ping' })); + setTimeout(() => this.sendPing(), 30000); // Ping every 30 seconds + } + } + + send(type, data) { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type, data })); + } + } + + handleSync(data) { + // Handle synchronization data + console.log('Sync data received:', data); + } + + handleEntityChanges(changes) { + // Handle entity change notifications + changes.forEach(change => { + console.log(`Entity ${change.entityName} ${change.entityId} changed`); + }); + } + + refreshTree() { + // Refresh the note tree UI + console.log('Tree refresh requested'); + } + + handleNoteCreated(note) { + console.log('Note created:', note); + } + + handleNoteUpdated(note) { + console.log('Note updated:', note); + } + + handleNoteDeleted(noteId) { + console.log('Note deleted:', noteId); + } + + disconnect() { + this.shouldReconnect = false; + if (this.ws) { + this.ws.close(); + } + } +} + +// Usage +const ws = new TriliumWebSocket(); +ws.connect(); + +// Send custom message +ws.send('log-info', { info: 'Client started' }); + +// Clean up on page unload +window.addEventListener('beforeunload', () => { + ws.disconnect(); +}); +``` + +### Message Types + +#### Incoming Messages + +| Type | Description | Data Format | +| --- | --- | --- | +| `sync` | Synchronization data | `{ entityChanges: [], lastSyncedPush: number }` | +| `entity-changes` | Entity modifications | `[{ entityName, entityId, action }]` | +| `refresh-tree` | Tree structure changed | None | +| `create-note` | Note created | Note object | +| `update-note` | Note updated | Note object | +| `delete-note` | Note deleted | `{ noteId }` | +| `frontend-script` | Execute frontend script | `{ script, params }` | + +#### Outgoing Messages + +| Type | Description | Data Format | +| --- | --- | --- | +| `ping` | Keep connection alive | None | +| `log-error` | Log client error | `{ error, stack }` | +| `log-info` | Log client info | `{ info }` | + +### Real-time Collaboration Example + +```javascript +class CollaborativeEditor { + constructor(noteId) { + this.noteId = noteId; + this.ws = new TriliumWebSocket(); + this.content = ''; + this.lastSaved = ''; + + this.ws.handleNoteUpdated = (note) => { + if (note.noteId === this.noteId) { + this.handleRemoteUpdate(note); + } + }; + } + + async loadNote() { + const response = await fetch(`/api/notes/${this.noteId}/content`, { + credentials: 'include' + }); + this.content = await response.text(); + this.lastSaved = this.content; + } + + handleRemoteUpdate(note) { + // Check if the update is from another client + if (this.content !== this.lastSaved) { + // Show conflict resolution UI + this.showConflictDialog(note); + } else { + // Apply remote changes + this.loadNote(); + } + } + + async saveContent(content) { + this.content = content; + + await fetch(`/api/notes/${this.noteId}/content`, { + method: 'PUT', + headers: { + 'Content-Type': 'text/html', + 'X-CSRF-Token': csrfToken + }, + body: content, + credentials: 'include' + }); + + this.lastSaved = content; + } + + showConflictDialog(remoteNote) { + // Implementation of conflict resolution UI + console.log('Conflict detected with remote changes'); + } +} +``` + +## File Operations + +### Upload File + +**POST** `/api/notes/{noteId}/attachments/upload` + +```javascript +const formData = new FormData(); +formData.append('file', fileInput.files[0]); + +const response = await fetch(`/api/notes/${noteId}/attachments/upload`, { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + body: formData, + credentials: 'include' +}); + +const attachment = await response.json(); +``` + +### Download Attachment + +**GET** `/api/attachments/{attachmentId}/download` + +```javascript +const response = await fetch(`/api/attachments/${attachmentId}/download`, { + credentials: 'include' +}); + +const blob = await response.blob(); +const url = URL.createObjectURL(blob); +const a = document.createElement('a'); +a.href = url; +a.download = 'attachment.pdf'; +a.click(); +``` + +### Upload Image + +**POST** `/api/images/upload` + +```javascript +const formData = new FormData(); +formData.append('image', imageFile); +formData.append('noteId', noteId); + +const response = await fetch('/api/images/upload', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + body: formData, + credentials: 'include' +}); + +const { url, noteId: imageNoteId } = await response.json(); +``` + +## Import/Export Operations + +### Import ZIP + +**POST** `/api/import` + +```javascript +const formData = new FormData(); +formData.append('file', zipFile); +formData.append('parentNoteId', 'root'); + +const response = await fetch('/api/import', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + body: formData, + credentials: 'include' +}); + +const result = await response.json(); +``` + +### Export Subtree + +**GET** `/api/notes/{noteId}/export` + +```javascript +const params = new URLSearchParams({ + format: 'html', // or 'markdown' + exportRevisions: 'true' +}); + +const response = await fetch(`/api/notes/${noteId}/export?${params}`, { + credentials: 'include' +}); + +const blob = await response.blob(); +const url = URL.createObjectURL(blob); +const a = document.createElement('a'); +a.href = url; +a.download = 'export.zip'; +a.click(); +``` + +### Import Markdown + +**POST** `/api/import/markdown` + +```javascript +const response = await fetch('/api/import/markdown', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + parentNoteId: 'root', + content: '# Markdown Content\n\nParagraph text...', + title: 'Imported from Markdown' + }), + credentials: 'include' +}); +``` + +### Export as PDF + +**GET** `/api/notes/{noteId}/export/pdf` + +```javascript +const response = await fetch(`/api/notes/${noteId}/export/pdf`, { + credentials: 'include' +}); + +const blob = await response.blob(); +const url = URL.createObjectURL(blob); +window.open(url, '_blank'); +``` + +## Synchronization API + +### Get Sync Status + +**GET** `/api/sync/status` + +```javascript +const status = await fetch('/api/sync/status', { + credentials: 'include' +}).then(r => r.json()); + +console.log('Sync enabled:', status.syncEnabled); +console.log('Last sync:', status.lastSyncedPush); +``` + +### Force Sync + +**POST** `/api/sync/now` + +```javascript +await fetch('/api/sync/now', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +### Get Sync Log + +**GET** `/api/sync/log` + +```javascript +const log = await fetch('/api/sync/log', { + credentials: 'include' +}).then(r => r.json()); + +log.forEach(entry => { + console.log(`${entry.date}: ${entry.message}`); +}); +``` + +## Script Execution + +### Execute Script + +**POST** `/api/script/run` + +```javascript +const response = await fetch('/api/script/run', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + script: ` + const note = await api.getNote('root'); + return { title: note.title, children: note.children.length }; + `, + params: {} + }), + credentials: 'include' +}); + +const result = await response.json(); +``` + +### Execute Note Script + +**POST** `/api/notes/{noteId}/run` + +Run a script note: + +```javascript +const response = await fetch(`/api/notes/${scriptNoteId}/run`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + params: { + targetNoteId: 'someNoteId' + } + }), + credentials: 'include' +}); + +const result = await response.json(); +``` + +## Special Features + +### Calendar API + +#### Get Day Note + +**GET** `/api/calendar/days/{date}` + +```javascript +const date = '2024-01-15'; +const dayNote = await fetch(`/api/calendar/days/${date}`, { + credentials: 'include' +}).then(r => r.json()); +``` + +#### Get Week Note + +**GET** `/api/calendar/weeks/{date}` + +```javascript +const weekNote = await fetch(`/api/calendar/weeks/2024-01-15`, { + credentials: 'include' +}).then(r => r.json()); +``` + +#### Get Month Note + +**GET** `/api/calendar/months/{month}` + +```javascript +const monthNote = await fetch(`/api/calendar/months/2024-01`, { + credentials: 'include' +}).then(r => r.json()); +``` + +### Inbox Note + +**GET** `/api/inbox/{date}` + +```javascript +const inboxNote = await fetch(`/api/inbox/2024-01-15`, { + credentials: 'include' +}).then(r => r.json()); +``` + +### Note Map + +**GET** `/api/notes/{noteId}/map` + +Get visual map data for a note: + +```javascript +const mapData = await fetch(`/api/notes/${noteId}/map`, { + credentials: 'include' +}).then(r => r.json()); + +// Returns nodes and links for visualization +console.log('Nodes:', mapData.nodes); +console.log('Links:', mapData.links); +``` + +### Similar Notes + +**GET** `/api/notes/{noteId}/similar` + +Find notes similar to the given note: + +```javascript +const similarNotes = await fetch(`/api/notes/${noteId}/similar`, { + credentials: 'include' +}).then(r => r.json()); +``` + +## Options and Configuration + +### Get All Options + +**GET** `/api/options` + +```javascript +const options = await fetch('/api/options', { + credentials: 'include' +}).then(r => r.json()); +``` + +### Update Option + +**PUT** `/api/options/{optionName}` + +```javascript +await fetch(`/api/options/theme`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + value: 'dark' + }), + credentials: 'include' +}); +``` + +### Get User Preferences + +**GET** `/api/options/user` + +```javascript +const preferences = await fetch('/api/options/user', { + credentials: 'include' +}).then(r => r.json()); +``` + +## Database Operations + +### Backup Database + +**POST** `/api/database/backup` + +```javascript +const response = await fetch('/api/database/backup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + backupName: 'manual-backup' + }), + credentials: 'include' +}); + +const { backupFile } = await response.json(); +``` + +### Vacuum Database + +**POST** `/api/database/vacuum` + +```javascript +await fetch('/api/database/vacuum', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +### Get Database Info + +**GET** `/api/database/info` + +```javascript +const info = await fetch('/api/database/info', { + credentials: 'include' +}).then(r => r.json()); + +console.log('Database size:', info.size); +console.log('Note count:', info.noteCount); +console.log('Revision count:', info.revisionCount); +``` + +## When to Use Internal vs ETAPI + +### Use Internal API When: + +* Building custom Trilium clients +* Needing WebSocket real-time updates +* Requiring full feature parity with the UI +* Working within the Trilium frontend environment +* Accessing advanced features not available in ETAPI + +### Use ETAPI When: + +* Building external integrations +* Creating automation scripts +* Developing third-party applications +* Needing stable, documented API +* Working with different programming languages + +### Feature Comparison + +| Feature | Internal API | ETAPI | +| --- | --- | --- | +| **Authentication** | Session/Cookie | Token | +| **CSRF Protection** | Required | Not needed | +| **WebSocket** | Yes | No | +| **Stability** | May change | Stable | +| **Documentation** | Limited | Comprehensive | +| **Real-time updates** | Yes | No | +| **File uploads** | Complex | Simple | +| **Scripting** | Full support | Limited | +| **Synchronization** | Yes | No | + +## Security Considerations + +### CSRF Protection + +All state-changing operations require a CSRF token: + +```javascript +// Get CSRF token from meta tag or API +async function getCsrfToken() { + const response = await fetch('/api/csrf-token', { + credentials: 'include' + }); + const { token } = await response.json(); + return token; +} + +// Use in requests +const csrfToken = await getCsrfToken(); + +await fetch('/api/notes', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify(data), + credentials: 'include' +}); +``` + +### Session Management + +```javascript +class TriliumSession { + constructor() { + this.isAuthenticated = false; + this.csrfToken = null; + } + + async login(password) { + const formData = new URLSearchParams(); + formData.append('password', password); + + const response = await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: formData, + credentials: 'include' + }); + + if (response.ok) { + this.isAuthenticated = true; + this.csrfToken = await this.getCsrfToken(); + return true; + } + + return false; + } + + async getCsrfToken() { + const response = await fetch('/api/csrf-token', { + credentials: 'include' + }); + const { token } = await response.json(); + return token; + } + + async request(url, options = {}) { + if (!this.isAuthenticated) { + throw new Error('Not authenticated'); + } + + const headers = { + ...options.headers + }; + + if (options.method && options.method !== 'GET') { + headers['X-CSRF-Token'] = this.csrfToken; + } + + return fetch(url, { + ...options, + headers, + credentials: 'include' + }); + } + + async logout() { + await this.request('/api/logout', { method: 'POST' }); + this.isAuthenticated = false; + this.csrfToken = null; + } +} + +// Usage +const session = new TriliumSession(); +await session.login('password'); + +// Make authenticated requests +const notes = await session.request('/api/notes/root').then(r => r.json()); + +// Create note with CSRF protection +await session.request('/api/notes/root/children', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'New Note', type: 'text' }) +}); + +await session.logout(); +``` + +### Protected Notes + +Handle encrypted notes properly: + +```javascript +class ProtectedNoteHandler { + constructor(session) { + this.session = session; + this.protectedSessionTimeout = null; + } + + async enterProtectedSession(password) { + const response = await this.session.request('/api/login/protected', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + + if (response.ok) { + // Protected session expires after inactivity + this.resetProtectedSessionTimeout(); + return true; + } + + return false; + } + + resetProtectedSessionTimeout() { + if (this.protectedSessionTimeout) { + clearTimeout(this.protectedSessionTimeout); + } + + // Assume 5 minute timeout + this.protectedSessionTimeout = setTimeout(() => { + console.log('Protected session expired'); + this.onProtectedSessionExpired(); + }, 5 * 60 * 1000); + } + + async accessProtectedNote(noteId) { + try { + const note = await this.session.request(`/api/notes/${noteId}`) + .then(r => r.json()); + + if (note.isProtected) { + // Reset timeout on successful access + this.resetProtectedSessionTimeout(); + } + + return note; + } catch (error) { + if (error.message.includes('Protected session required')) { + // Prompt for password + const password = await this.promptForPassword(); + if (await this.enterProtectedSession(password)) { + return this.accessProtectedNote(noteId); + } + } + throw error; + } + } + + async promptForPassword() { + // Implementation depends on UI framework + return prompt('Enter password for protected notes:'); + } + + onProtectedSessionExpired() { + // Handle expiration (e.g., show notification, lock UI) + console.log('Please re-enter password to access protected notes'); + } +} +``` + +## Error Handling + +### Common Error Responses + +```javascript +// 401 Unauthorized +{ + "status": 401, + "message": "Authentication required" +} + +// 403 Forbidden +{ + "status": 403, + "message": "CSRF token validation failed" +} + +// 404 Not Found +{ + "status": 404, + "message": "Note 'invalidId' not found" +} + +// 400 Bad Request +{ + "status": 400, + "message": "Invalid note type: 'invalid'" +} + +// 500 Internal Server Error +{ + "status": 500, + "message": "Database error", + "stack": "..." // Only in development +} +``` + +### Error Handler Implementation + +```javascript +class APIErrorHandler { + async handleResponse(response) { + if (!response.ok) { + const error = await this.parseError(response); + + switch (response.status) { + case 401: + this.handleAuthError(error); + break; + case 403: + this.handleForbiddenError(error); + break; + case 404: + this.handleNotFoundError(error); + break; + case 400: + this.handleBadRequestError(error); + break; + case 500: + this.handleServerError(error); + break; + default: + this.handleGenericError(error); + } + + throw error; + } + + return response; + } + + async parseError(response) { + try { + const errorData = await response.json(); + return new APIError( + response.status, + errorData.message || response.statusText, + errorData + ); + } catch { + return new APIError( + response.status, + response.statusText + ); + } + } + + handleAuthError(error) { + console.error('Authentication required'); + // Redirect to login + window.location.href = '/login'; + } + + handleForbiddenError(error) { + if (error.message.includes('CSRF')) { + console.error('CSRF token invalid, refreshing...'); + // Refresh CSRF token + this.refreshCsrfToken(); + } else { + console.error('Access forbidden:', error.message); + } + } + + handleNotFoundError(error) { + console.error('Resource not found:', error.message); + } + + handleBadRequestError(error) { + console.error('Bad request:', error.message); + } + + handleServerError(error) { + console.error('Server error:', error.message); + // Show user-friendly error message + this.showErrorNotification('An error occurred. Please try again later.'); + } + + handleGenericError(error) { + console.error('API error:', error); + } + + showErrorNotification(message) { + // Implementation depends on UI framework + alert(message); + } +} + +class APIError extends Error { + constructor(status, message, data = {}) { + super(message); + this.status = status; + this.data = data; + this.name = 'APIError'; + } +} +``` + +## Performance Optimization + +### Request Batching + +```javascript +class BatchedAPIClient { + constructor() { + this.batchQueue = []; + this.batchTimeout = null; + this.batchDelay = 50; // ms + } + + async batchRequest(request) { + return new Promise((resolve, reject) => { + this.batchQueue.push({ request, resolve, reject }); + + if (!this.batchTimeout) { + this.batchTimeout = setTimeout(() => { + this.processBatch(); + }, this.batchDelay); + } + }); + } + + async processBatch() { + const batch = this.batchQueue.splice(0); + this.batchTimeout = null; + + if (batch.length === 0) return; + + try { + const response = await fetch('/api/batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + requests: batch.map(b => b.request) + }), + credentials: 'include' + }); + + const results = await response.json(); + + batch.forEach((item, index) => { + if (results[index].error) { + item.reject(new Error(results[index].error)); + } else { + item.resolve(results[index].data); + } + }); + } catch (error) { + batch.forEach(item => item.reject(error)); + } + } + + async getNote(noteId) { + return this.batchRequest({ + method: 'GET', + url: `/api/notes/${noteId}` + }); + } + + async getAttribute(attributeId) { + return this.batchRequest({ + method: 'GET', + url: `/api/attributes/${attributeId}` + }); + } +} + +// Usage +const client = new BatchedAPIClient(); + +// These requests will be batched +const [note1, note2, note3] = await Promise.all([ + client.getNote('noteId1'), + client.getNote('noteId2'), + client.getNote('noteId3') +]); +``` + +### Caching Strategy + +```javascript +class CachedAPIClient { + constructor() { + this.cache = new Map(); + this.cacheExpiry = new Map(); + this.defaultTTL = 5 * 60 * 1000; // 5 minutes + } + + getCacheKey(method, url, params = {}) { + return `${method}:${url}:${JSON.stringify(params)}`; + } + + isExpired(key) { + const expiry = this.cacheExpiry.get(key); + return !expiry || Date.now() > expiry; + } + + async cachedRequest(method, url, options = {}, ttl = this.defaultTTL) { + const key = this.getCacheKey(method, url, options.params); + + if (method === 'GET' && this.cache.has(key) && !this.isExpired(key)) { + return this.cache.get(key); + } + + const response = await fetch(url, { + method, + ...options, + credentials: 'include' + }); + + const data = await response.json(); + + if (method === 'GET') { + this.cache.set(key, data); + this.cacheExpiry.set(key, Date.now() + ttl); + } + + return data; + } + + invalidate(pattern) { + for (const key of this.cache.keys()) { + if (key.includes(pattern)) { + this.cache.delete(key); + this.cacheExpiry.delete(key); + } + } + } + + async getNote(noteId) { + return this.cachedRequest('GET', `/api/notes/${noteId}`); + } + + async updateNote(noteId, data) { + const result = await fetch(`/api/notes/${noteId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify(data), + credentials: 'include' + }).then(r => r.json()); + + // Invalidate cache for this note + this.invalidate(`/api/notes/${noteId}`); + + return result; + } +} +``` + +## Advanced Examples + +### Building a Note Explorer + +```javascript +class NoteExplorer { + constructor() { + this.currentNote = null; + this.history = []; + this.historyIndex = -1; + } + + async navigateToNote(noteId) { + // Add to history + if (this.historyIndex < this.history.length - 1) { + this.history = this.history.slice(0, this.historyIndex + 1); + } + this.history.push(noteId); + this.historyIndex++; + + // Load note + this.currentNote = await this.loadNoteWithChildren(noteId); + this.render(); + } + + async loadNoteWithChildren(noteId) { + const [note, children] = await Promise.all([ + fetch(`/api/notes/${noteId}`, { credentials: 'include' }) + .then(r => r.json()), + fetch(`/api/notes/${noteId}/children`, { credentials: 'include' }) + .then(r => r.json()) + ]); + + return { ...note, children }; + } + + canGoBack() { + return this.historyIndex > 0; + } + + canGoForward() { + return this.historyIndex < this.history.length - 1; + } + + async goBack() { + if (this.canGoBack()) { + this.historyIndex--; + const noteId = this.history[this.historyIndex]; + this.currentNote = await this.loadNoteWithChildren(noteId); + this.render(); + } + } + + async goForward() { + if (this.canGoForward()) { + this.historyIndex++; + const noteId = this.history[this.historyIndex]; + this.currentNote = await this.loadNoteWithChildren(noteId); + this.render(); + } + } + + async searchInSubtree(query) { + const params = new URLSearchParams({ + query: query, + ancestorNoteId: this.currentNote.noteId, + includeArchivedNotes: 'false' + }); + + const response = await fetch(`/api/search?${params}`, { + credentials: 'include' + }); + + return response.json(); + } + + async createChildNote(title, content, type = 'text') { + const response = await fetch(`/api/notes/${this.currentNote.noteId}/children`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': await getCsrfToken() + }, + body: JSON.stringify({ title, content, type }), + credentials: 'include' + }); + + const result = await response.json(); + + // Refresh current note to show new child + this.currentNote = await this.loadNoteWithChildren(this.currentNote.noteId); + this.render(); + + return result; + } + + render() { + // Render UI - implementation depends on framework + console.log('Current note:', this.currentNote.title); + console.log('Children:', this.currentNote.children.map(c => c.title)); + } +} + +// Usage +const explorer = new NoteExplorer(); +await explorer.navigateToNote('root'); +await explorer.createChildNote('New Child', '

Content

'); +const searchResults = await explorer.searchInSubtree('keyword'); +``` + +### Building a Task Management System + +```javascript +class TaskManager { + constructor() { + this.taskRootId = null; + this.csrfToken = null; + } + + async initialize() { + this.csrfToken = await getCsrfToken(); + this.taskRootId = await this.getOrCreateTaskRoot(); + } + + async getOrCreateTaskRoot() { + // Search for existing task root + const searchParams = new URLSearchParams({ query: '#taskRoot' }); + const searchResponse = await fetch(`/api/search?${searchParams}`, { + credentials: 'include' + }); + const { results } = await searchResponse.json(); + + if (results.length > 0) { + return results[0].noteId; + } + + // Create task root + const response = await fetch('/api/notes/root/children', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify({ + title: 'Tasks', + type: 'text', + content: '

Task Management

' + }), + credentials: 'include' + }); + + const { note } = await response.json(); + + // Add taskRoot label + await this.addLabel(note.noteId, 'taskRoot'); + + return note.noteId; + } + + async createTask(title, description, priority = 'medium', dueDate = null) { + // Create task note + const response = await fetch(`/api/notes/${this.taskRootId}/children`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify({ + title, + type: 'text', + content: `

${title}

${description}

` + }), + credentials: 'include' + }); + + const { note } = await response.json(); + + // Add task metadata + await Promise.all([ + this.addLabel(note.noteId, 'task'), + this.addLabel(note.noteId, 'status', 'todo'), + this.addLabel(note.noteId, 'priority', priority), + dueDate ? this.addLabel(note.noteId, 'dueDate', dueDate) : null + ].filter(Boolean)); + + return note; + } + + async addLabel(noteId, name, value = '') { + await fetch(`/api/notes/${noteId}/attributes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify({ + type: 'label', + name, + value, + isInheritable: false + }), + credentials: 'include' + }); + } + + async getTasks(status = null, priority = null) { + let query = '#task'; + if (status) query += ` #status=${status}`; + if (priority) query += ` #priority=${priority}`; + + const params = new URLSearchParams({ + query, + ancestorNoteId: this.taskRootId, + orderBy: 'dateModified', + orderDirection: 'desc' + }); + + const response = await fetch(`/api/search?${params}`, { + credentials: 'include' + }); + + const { results } = await response.json(); + return results; + } + + async updateTaskStatus(noteId, newStatus) { + // Get task attributes + const note = await fetch(`/api/notes/${noteId}`, { + credentials: 'include' + }).then(r => r.json()); + + // Find status attribute + const statusAttr = note.attributes.find(a => a.name === 'status'); + + if (statusAttr) { + // Update existing status + await fetch(`/api/attributes/${statusAttr.attributeId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify({ value: newStatus }), + credentials: 'include' + }); + } else { + // Add status attribute + await this.addLabel(noteId, 'status', newStatus); + } + + // Add completion timestamp if marking as done + if (newStatus === 'done') { + const timestamp = new Date().toISOString(); + await this.addLabel(noteId, 'completedAt', timestamp); + } + } + + async getTaskStats() { + const [todoTasks, inProgressTasks, doneTasks] = await Promise.all([ + this.getTasks('todo'), + this.getTasks('in-progress'), + this.getTasks('done') + ]); + + return { + todo: todoTasks.length, + inProgress: inProgressTasks.length, + done: doneTasks.length, + total: todoTasks.length + inProgressTasks.length + doneTasks.length + }; + } +} + +// Usage +const taskManager = new TaskManager(); +await taskManager.initialize(); + +// Create tasks +const task1 = await taskManager.createTask( + 'Review Documentation', + 'Review and update API documentation', + 'high', + '2024-01-20' +); + +const task2 = await taskManager.createTask( + 'Fix Bug #123', + 'Investigate and fix the reported issue', + 'medium' +); + +// Get tasks +const todoTasks = await taskManager.getTasks('todo'); +console.log('Todo tasks:', todoTasks); + +// Update task status +await taskManager.updateTaskStatus(task1.noteId, 'in-progress'); + +// Get statistics +const stats = await taskManager.getTaskStats(); +console.log('Task statistics:', stats); +``` + +## Conclusion + +The Internal API provides complete access to Trilium's functionality but should be used with caution due to its complexity and potential for changes. For most external integrations, [ETAPI](ETAPI%20Complete%20Guide.md) is the recommended choice due to its stability and comprehensive documentation. + +Key takeaways: + +* Always include CSRF tokens for state-changing operations +* Handle session management carefully +* Use WebSocket for real-time updates +* Implement proper error handling +* Consider using ETAPI for external integrations +* Cache responses when appropriate for better performance + +For additional information, refer to: + +* [ETAPI Complete Guide](ETAPI%20Complete%20Guide.md) +* [Script API Cookbook](Script%20API%20Cookbook.md) +* [WebSocket API Documentation](WebSocket%20API.md) \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/API Documentation/Script API Cookbook.md b/docs/Developer Guide/Developer Guide/API Documentation/Script API Cookbook.md new file mode 100644 index 0000000000..0338444793 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/Script API Cookbook.md @@ -0,0 +1,1845 @@ +# Script API Cookbook +## Table of Contents + +1. [Introduction](#introduction) +2. [Backend Script Recipes](#backend-script-recipes) +3. [Frontend Script Recipes](#frontend-script-recipes) +4. [Common Patterns](#common-patterns) +5. [Note Manipulation](#note-manipulation) +6. [Attribute Operations](#attribute-operations) +7. [Search and Filtering](#search-and-filtering) +8. [Automation Examples](#automation-examples) +9. [Integration with External Services](#integration-with-external-services) +10. [Custom Widgets](#custom-widgets) +11. [Event Handling](#event-handling) +12. [Best Practices](#best-practices) + +## Introduction + +Trilium's Script API provides powerful automation capabilities through JavaScript code that runs either on the backend (Node.js) or frontend (browser). This cookbook contains practical recipes and patterns for common scripting tasks. + +### Script Types + +| Type | Environment | Access | Use Cases | +| --- | --- | --- | --- | +| **Backend Script** | Node.js | Full database, file system, network | Automation, data processing, integrations | +| **Frontend Script** | Browser | UI manipulation, user interaction | Custom widgets, UI enhancements | +| **Custom Widget** | Browser | Widget lifecycle, note context | Interactive components, visualizations | + +### Basic Script Structure + +**Backend Script:** + +```javascript +// Access to api object is automatic +const note = await api.getNoteWithLabel('todoList'); +const children = await note.getChildNotes(); + +// Return value becomes script output +return { + noteTitle: note.title, + childCount: children.length +}; +``` + +**Frontend Script:** + +```javascript +// Access to api object is automatic +api.showMessage('Script executed!'); + +// Manipulate UI +const $button = $(' + + + +`); + +// Create overlay +const $overlay = $(` +
+`); + +// Add to page +$('body').append($button, $overlay, $modal); + +// Handle button click +$button.click(() => { + $overlay.show(); + $modal.show(); + $('#quick-note-title').focus(); +}); + +// Handle save +$('#quick-note-save').click(async () => { + const title = $('#quick-note-title').val() || 'Quick Note'; + const content = $('#quick-note-content').val() || ''; + const type = $('#quick-note-type').val(); + + let finalContent = content; + + // Format based on type + if (type === 'task') { + finalContent = ` +

📋 ${title}

+
    +
  • [ ] ${content}
  • +
+ `; + } else if (type === 'code') { + finalContent = `// ${title}\n${content}`; + } else { + finalContent = `

${title}

${content}

`; + } + + // Get current note or use inbox + const currentNote = api.getActiveContextNote(); + const parentNoteId = currentNote ? currentNote.noteId : (await api.getDayNote()).noteId; + + // Create note + const { note } = await api.runOnBackend(async (parentId, noteTitle, noteContent, noteType) => { + const parent = await api.getNote(parentId); + const newNote = await api.createNote(parent, noteTitle, noteContent, noteType === 'code' ? 'code' : 'text'); + + if (noteType === 'task') { + await newNote.setLabel('task'); + await newNote.setLabel('created', api.dayjs().format()); + } + + return { note: newNote.getPojo() }; + }, [parentNoteId, title, finalContent, type]); + + api.showMessage(`Note "${title}" created!`); + + // Clear and close + $('#quick-note-title').val(''); + $('#quick-note-content').val(''); + $overlay.hide(); + $modal.hide(); + + // Navigate to new note + await api.activateNewNote(note.noteId); +}); + +// Handle cancel +$('#quick-note-cancel, #quick-note-overlay').click(() => { + $overlay.hide(); + $modal.hide(); +}); + +// Keyboard shortcuts +$(document).keydown((e) => { + // Ctrl+Shift+N to open quick note + if (e.ctrlKey && e.shiftKey && e.key === 'N') { + e.preventDefault(); + $button.click(); + } + + // Escape to close + if (e.key === 'Escape' && $modal.is(':visible')) { + $overlay.hide(); + $modal.hide(); + } +}); +``` + +### 7\. Note Graph Visualizer + +Create an interactive graph of note relationships: + +```javascript +// Load D3.js +await api.requireLibrary('d3'); + +// Create container +const $container = $(` +
+`); + +// Add to current note +const $noteDetail = $(`.note-detail-code`); +$noteDetail.empty().append($container); + +// Get note data +const graphData = await api.runOnBackend(async () => { + const currentNote = api.getActiveContextNote(); + const maxDepth = 3; + const nodes = []; + const links = []; + const visited = new Set(); + + async function traverse(note, depth = 0) { + if (!note || depth > maxDepth || visited.has(note.noteId)) { + return; + } + + visited.add(note.noteId); + + nodes.push({ + id: note.noteId, + title: note.title, + type: note.type, + depth: depth + }); + + // Get children + const children = await note.getChildNotes(); + for (const child of children) { + links.push({ + source: note.noteId, + target: child.noteId, + type: 'child' + }); + await traverse(child, depth + 1); + } + + // Get relations + const relations = await note.getRelations(); + for (const relation of relations) { + const targetNote = await relation.getTargetNote(); + if (targetNote) { + links.push({ + source: note.noteId, + target: targetNote.noteId, + type: 'relation', + name: relation.name + }); + + if (!visited.has(targetNote.noteId)) { + nodes.push({ + id: targetNote.noteId, + title: targetNote.title, + type: targetNote.type, + depth: depth + 1 + }); + visited.add(targetNote.noteId); + } + } + } + } + + await traverse(currentNote); + + return { nodes, links }; +}); + +// Create D3 visualization +const width = $container.width(); +const height = $container.height(); + +const svg = d3.select('#note-graph') + .append('svg') + .attr('width', width) + .attr('height', height); + +// Create force simulation +const simulation = d3.forceSimulation(graphData.nodes) + .force('link', d3.forceLink(graphData.links).id(d => d.id).distance(100)) + .force('charge', d3.forceManyBody().strength(-300)) + .force('center', d3.forceCenter(width / 2, height / 2)); + +// Create links +const link = svg.append('g') + .selectAll('line') + .data(graphData.links) + .enter().append('line') + .style('stroke', d => d.type === 'child' ? '#999' : '#f00') + .style('stroke-opacity', 0.6) + .style('stroke-width', d => d.type === 'child' ? 2 : 1); + +// Create nodes +const node = svg.append('g') + .selectAll('circle') + .data(graphData.nodes) + .enter().append('circle') + .attr('r', d => 10 - d.depth * 2) + .style('fill', d => { + const colors = { + text: '#4CAF50', + code: '#2196F3', + file: '#FF9800', + image: '#9C27B0' + }; + return colors[d.type] || '#666'; + }) + .call(d3.drag() + .on('start', dragstarted) + .on('drag', dragged) + .on('end', dragended)); + +// Add labels +const label = svg.append('g') + .selectAll('text') + .data(graphData.nodes) + .enter().append('text') + .text(d => d.title) + .style('font-size', '12px') + .style('fill', '#333'); + +// Add tooltips +node.append('title') + .text(d => d.title); + +// Update positions on tick +simulation.on('tick', () => { + link + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y); + + node + .attr('cx', d => d.x) + .attr('cy', d => d.y); + + label + .attr('x', d => d.x + 12) + .attr('y', d => d.y + 4); +}); + +// Drag functions +function dragstarted(event, d) { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; +} + +function dragged(event, d) { + d.fx = event.x; + d.fy = event.y; +} + +function dragended(event, d) { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; +} + +// Handle node clicks +node.on('click', async (event, d) => { + await api.activateNote(d.id); +}); +``` + +### 8\. Markdown Preview Toggle + +Add live markdown preview for notes: + +```javascript +// Create preview pane +const $previewPane = $(` +
+
+
+`); + +// Create toggle button +const $toggleBtn = $(` + +`); + +// Add to note detail +$('.note-detail-text').css('position', 'relative').append($previewPane, $toggleBtn); + +let previewVisible = false; +let updateTimeout; + +// Load markdown library +await api.requireLibrary('markdown-it'); +const md = window.markdownit({ + html: true, + linkify: true, + typographer: true, + breaks: true +}); + +// Toggle preview +$toggleBtn.click(() => { + previewVisible = !previewVisible; + + if (previewVisible) { + $previewPane.show(); + $('.note-detail-text .note-detail-editable').css('width', '50%'); + $toggleBtn.html(' Hide'); + updatePreview(); + } else { + $previewPane.hide(); + $('.note-detail-text .note-detail-editable').css('width', '100%'); + $toggleBtn.html(' Preview'); + } +}); + +// Update preview function +async function updatePreview() { + if (!previewVisible) return; + + const content = await api.getActiveContextTextEditor().getContent(); + + // Convert HTML to markdown first (simplified) + let markdown = content + .replace(/]*>(.*?)<\/h1>/g, '# $1\n') + .replace(/]*>(.*?)<\/h2>/g, '## $1\n') + .replace(/]*>(.*?)<\/h3>/g, '### $1\n') + .replace(/]*>(.*?)<\/p>/g, '$1\n\n') + .replace(/]*>(.*?)<\/strong>/g, '**$1**') + .replace(/]*>(.*?)<\/b>/g, '**$1**') + .replace(/]*>(.*?)<\/em>/g, '*$1*') + .replace(/]*>(.*?)<\/i>/g, '*$1*') + .replace(/]*>(.*?)<\/code>/g, '`$1`') + .replace(/]*>/g, '') + .replace(/<\/ul>/g, '\n') + .replace(/]*>(.*?)<\/li>/g, '- $1\n') + .replace(/]*>/g, '') + .replace(/<\/ol>/g, '\n') + .replace(/]*>(.*?)<\/li>/g, '1. $1\n') + .replace(/]*>(.*?)<\/a>/g, '[$2]($1)') + .replace(/]*src="api/images/Z7VzyCVBZwf1/([^"]*)"[^>]*alt="([^"]*)"[^>]*>/g, '![$2]($1)') + .replace(/]*>/g, '\n') + .replace(/<[^>]+>/g, ''); // Remove remaining HTML tags + + // Render markdown + const html = md.render(markdown); + + $('#preview-content').html(html); + + // Syntax highlight code blocks + $('#preview-content pre code').each(function() { + if (window.hljs) { + window.hljs.highlightElement(this); + } + }); +} + +// Auto-update preview on content change +api.bindGlobalShortcut('mod+s', async () => { + if (previewVisible) { + clearTimeout(updateTimeout); + updateTimeout = setTimeout(updatePreview, 500); + } +}); + +// Update on note change +api.onActiveContextNoteChange(async () => { + if (previewVisible) { + updatePreview(); + } +}); +``` + +## Common Patterns + +### 9\. Template System + +Create and apply templates to new notes: + +```javascript +// Backend script to manage templates + +async function createFromTemplate(templateName, targetParentId, customData = {}) { + // Find template + const template = await api.getNoteWithLabel(`template:${templateName}`); + if (!template) { + throw new Error(`Template "${templateName}" not found`); + } + + // Get template content and metadata + const content = await template.getContent(); + const attributes = await template.getAttributes(); + + // Process template variables + let processedContent = content; + const variables = { + DATE: api.dayjs().format('YYYY-MM-DD'), + TIME: api.dayjs().format('HH:mm:ss'), + DATETIME: api.dayjs().format('YYYY-MM-DD HH:mm:ss'), + USER: api.getAppInfo().username || 'User', + ...customData + }; + + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`{{${key}}}`, 'g'); + processedContent = processedContent.replace(regex, value); + } + + // Create new note + const parentNote = await api.getNote(targetParentId); + const title = customData.title || `${templateName} - ${variables.DATE}`; + const newNote = await api.createNote(parentNote, title, processedContent); + + // Copy attributes (except template label) + for (const attr of attributes) { + if (!attr.name.startsWith('template:')) { + if (attr.type === 'label') { + await newNote.setLabel(attr.name, attr.value); + } else if (attr.type === 'relation') { + await newNote.setRelation(attr.name, attr.value); + } + } + } + + return newNote; +} + +// Example: Meeting notes template +const meetingTemplate = ` +

Meeting Notes - {{DATE}}

+ + + + + + +
Date:{{DATE}}
Time:{{TIME}}
Attendees:{{ATTENDEES}}
Subject:{{SUBJECT}}
+ +

Agenda

+
    +
  • {{AGENDA_ITEM_1}}
  • +
  • {{AGENDA_ITEM_2}}
  • +
  • {{AGENDA_ITEM_3}}
  • +
+ +

Discussion

+

+ +

Action Items

+
    +
  • [ ]
  • +
+ +

Next Steps

+

+`; + +// Create template note if it doesn't exist +let templateNote = await api.getNoteWithLabel('template:meeting'); +if (!templateNote) { + templateNote = await api.createTextNote('root', 'Meeting Template', meetingTemplate); + await templateNote.setLabel('template:meeting'); + await templateNote.setLabel('hideFromTree'); // Hide template from tree +} + +// Use template +const meeting = await createFromTemplate('meeting', 'root', { + title: 'Team Standup', + ATTENDEES: 'John, Jane, Bob', + SUBJECT: 'Weekly Status Update', + AGENDA_ITEM_1: 'Review last week\'s tasks', + AGENDA_ITEM_2: 'Current blockers', + AGENDA_ITEM_3: 'Next week\'s priorities' +}); + +api.log(`Created meeting note: ${meeting.title}`); +``` + +### 10\. Hierarchical Tag System + +Implement hierarchical tags with inheritance: + +```javascript +class HierarchicalTags { + constructor() { + this.tagHierarchy = {}; + } + + async buildTagHierarchy() { + // Find all tag definition notes + const tagNotes = await api.searchForNotes('#tagDef'); + + for (const note of tagNotes) { + const tagName = await note.getLabel('tagName'); + const parentTag = await note.getLabel('parentTag'); + + if (tagName) { + this.tagHierarchy[tagName.value] = { + noteId: note.noteId, + parent: parentTag ? parentTag.value : null, + children: [] + }; + } + } + + // Build children arrays + for (const [tag, data] of Object.entries(this.tagHierarchy)) { + if (data.parent && this.tagHierarchy[data.parent]) { + this.tagHierarchy[data.parent].children.push(tag); + } + } + + return this.tagHierarchy; + } + + async applyHierarchicalTag(noteId, tagName) { + const note = await api.getNote(noteId); + + // Apply the tag + await note.setLabel(tagName); + + // Apply all parent tags + let currentTag = tagName; + while (this.tagHierarchy[currentTag] && this.tagHierarchy[currentTag].parent) { + const parentTag = this.tagHierarchy[currentTag].parent; + await note.setLabel(parentTag); + currentTag = parentTag; + } + } + + async getNotesWithTagHierarchy(tagName) { + // Get all child tags + const allTags = [tagName]; + const queue = [tagName]; + + while (queue.length > 0) { + const current = queue.shift(); + if (this.tagHierarchy[current]) { + for (const child of this.tagHierarchy[current].children) { + allTags.push(child); + queue.push(child); + } + } + } + + // Search for notes with any of these tags + const searchQuery = allTags.map(t => `#${t}`).join(' OR '); + return await api.searchForNotes(searchQuery); + } + + async createTagReport() { + await this.buildTagHierarchy(); + + let report = '

Tag Hierarchy Report

\n'; + + // Build tree visualization + const renderTree = (tag, level = 0) => { + const indent = ' '.repeat(level * 4); + let html = `${indent}• ${tag}`; + + const notes = api.searchForNotes(`#${tag}`); + html += ` (${notes.length} notes)
\n`; + + if (this.tagHierarchy[tag] && this.tagHierarchy[tag].children.length > 0) { + for (const child of this.tagHierarchy[tag].children) { + html += renderTree(child, level + 1); + } + } + + return html; + }; + + // Find root tags (no parent) + const rootTags = Object.keys(this.tagHierarchy) + .filter(tag => !this.tagHierarchy[tag].parent); + + for (const rootTag of rootTags) { + report += renderTree(rootTag); + } + + // Create or update report note + let reportNote = await api.getNoteWithLabel('tagHierarchyReport'); + if (!reportNote) { + reportNote = await api.createTextNote('root', 'Tag Hierarchy Report', ''); + await reportNote.setLabel('tagHierarchyReport'); + } + + await reportNote.setContent(report); + + return report; + } +} + +// Usage +const tagSystem = new HierarchicalTags(); + +// Define tag hierarchy +const createTagDefinition = async (tagName, parentTag = null) => { + let tagDef = await api.getNoteWithLabel(`tagDef:${tagName}`); + if (!tagDef) { + tagDef = await api.createTextNote('root', `Tag: ${tagName}`, `Tag definition for ${tagName}`); + await tagDef.setLabel('tagDef'); + await tagDef.setLabel(`tagDef:${tagName}`); + await tagDef.setLabel('tagName', tagName); + if (parentTag) { + await tagDef.setLabel('parentTag', parentTag); + } + } + return tagDef; +}; + +// Create tag hierarchy +await createTagDefinition('project'); +await createTagDefinition('work', 'project'); +await createTagDefinition('personal', 'project'); +await createTagDefinition('development', 'work'); +await createTagDefinition('documentation', 'work'); + +// Apply hierarchical tag +await tagSystem.buildTagHierarchy(); +await tagSystem.applyHierarchicalTag('someNoteId', 'documentation'); +// This will also apply 'work' and 'project' tags + +// Get all notes in hierarchy +const projectNotes = await tagSystem.getNotesWithTagHierarchy('project'); +// Returns notes tagged with 'project', 'work', 'personal', 'development', or 'documentation' + +// Generate report +await tagSystem.createTagReport(); +``` + +## Integration with External Services + +### 11\. GitHub Integration + +Sync GitHub issues with notes: + +```javascript +// Requires axios library +const axios = require('axios'); + +class GitHubSync { + constructor(token, repo) { + this.token = token; + this.repo = repo; // format: "owner/repo" + this.apiBase = 'https://api.github.com'; + } + + async getIssues(state = 'open') { + const response = await axios.get(`${this.apiBase}/repos/${this.repo}/issues`, { + headers: { + 'Authorization': `token ${this.token}`, + 'Accept': 'application/vnd.github.v3+json' + }, + params: { state } + }); + + return response.data; + } + + async syncIssuesToNotes() { + // Get or create GitHub folder + let githubFolder = await api.getNoteWithLabel('githubSync'); + if (!githubFolder) { + githubFolder = await api.createTextNote('root', 'GitHub Issues', ''); + await githubFolder.setLabel('githubSync'); + } + + const issues = await this.getIssues(); + const syncedNotes = []; + + for (const issue of issues) { + // Check if issue note already exists + let issueNote = await api.getNoteWithLabel(`github:issue:${issue.number}`); + + const content = ` +

${issue.title}

+ + + + + + + + +
Issue #${issue.number}
State${issue.state}
Author${issue.user.login}
Created${api.dayjs(issue.created_at).format('YYYY-MM-DD HH:mm')}
Updated${api.dayjs(issue.updated_at).format('YYYY-MM-DD HH:mm')}
Labels${issue.labels.map(l => l.name).join(', ')}
+ +

Description

+
+ ${issue.body || 'No description'} +
+ +

Links

+
+ `; + + if (!issueNote) { + // Create new note + issueNote = await api.createNote( + githubFolder, + `#${issue.number}: ${issue.title}`, + content + ); + await issueNote.setLabel(`github:issue:${issue.number}`); + } else { + // Update existing note + await issueNote.setContent(content); + } + + // Set labels based on issue state and labels + await issueNote.setLabel('githubIssue'); + await issueNote.setLabel('state', issue.state); + + for (const label of issue.labels) { + await issueNote.setLabel(`gh:${label.name}`); + } + + syncedNotes.push({ + noteId: issueNote.noteId, + issueNumber: issue.number, + title: issue.title + }); + } + + api.log(`Synced ${syncedNotes.length} GitHub issues`); + return syncedNotes; + } + + async createIssueFromNote(noteId) { + const note = await api.getNote(noteId); + const content = await note.getContent(); + + // Extract plain text from HTML + const plainText = content.replace(/<[^>]*>/g, ''); + + const response = await axios.post( + `${this.apiBase}/repos/${this.repo}/issues`, + { + title: note.title, + body: plainText, + labels: ['from-trilium'] + }, + { + headers: { + 'Authorization': `token ${this.token}`, + 'Accept': 'application/vnd.github.v3+json' + } + } + ); + + // Link note to issue + await note.setLabel(`github:issue:${response.data.number}`); + await note.setLabel('githubIssue'); + + return response.data; + } +} + +// Usage +const github = new GitHubSync( + process.env.GITHUB_TOKEN || 'your-token', + 'your-org/your-repo' +); + +// Sync issues to notes +const synced = await github.syncIssuesToNotes(); + +// Create issue from current note +// const issue = await github.createIssueFromNote('currentNoteId'); +``` + +### 12\. Email Integration + +Send notes via email: + +```javascript +const nodemailer = require('nodemailer'); + +class EmailIntegration { + constructor(config) { + this.transporter = nodemailer.createTransporter({ + host: config.host || 'smtp.gmail.com', + port: config.port || 587, + secure: false, + auth: { + user: config.user, + pass: config.pass + } + }); + } + + async sendNoteAsEmail(noteId, to, options = {}) { + const note = await api.getNote(noteId); + const content = await note.getContent(); + + // Get attachments + const attachments = await note.getAttachments(); + const mailAttachments = []; + + for (const attachment of attachments) { + const blob = await attachment.getBlob(); + mailAttachments.push({ + filename: attachment.title, + content: blob.content, + contentType: attachment.mime + }); + } + + // Convert note content to email-friendly HTML + const emailHtml = ` + + + + + + + ${content} +
+

+ Sent from Trilium Notes on ${api.dayjs().format('YYYY-MM-DD HH:mm:ss')} +

+ + + `; + + const mailOptions = { + from: options.from || this.transporter.options.auth.user, + to: to, + subject: options.subject || note.title, + html: emailHtml, + attachments: mailAttachments + }; + + const info = await this.transporter.sendMail(mailOptions); + + // Log email send + await note.setLabel('emailSent', api.dayjs().format()); + await note.setLabel('emailRecipient', to); + + api.log(`Email sent: ${info.messageId}`); + + return info; + } + + async createEmailCampaign(templateNoteId, recipientListNoteId) { + const template = await api.getNote(templateNoteId); + const recipientNote = await api.getNote(recipientListNoteId); + const recipientContent = await recipientNote.getContent(); + + // Parse recipient list (assume one email per line) + const recipients = recipientContent + .split('\n') + .map(line => line.trim()) + .filter(line => line && line.includes('@')); + + const results = []; + + for (const recipient of recipients) { + try { + const result = await this.sendNoteAsEmail( + templateNoteId, + recipient, + { + subject: template.title + } + ); + + results.push({ + recipient, + success: true, + messageId: result.messageId + }); + + // Add delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (error) { + results.push({ + recipient, + success: false, + error: error.message + }); + } + } + + // Create campaign report + const reportNote = await api.createTextNote( + 'root', + `Email Campaign Report - ${api.dayjs().format('YYYY-MM-DD')}`, + ` +

Email Campaign Report

+

Template: ${template.title}

+

Sent: ${api.dayjs().format('YYYY-MM-DD HH:mm:ss')}

+

Total Recipients: ${recipients.length}

+

Successful: ${results.filter(r => r.success).length}

+

Failed: ${results.filter(r => !r.success).length}

+ +

Results

+ + + ${results.map(r => ` + + + + + + `).join('')} +
RecipientStatusDetails
${r.recipient}${r.success ? '✅ Sent' : '❌ Failed'}${r.success ? r.messageId : r.error}
+ ` + ); + + await reportNote.setLabel('emailCampaignReport'); + + return results; + } +} + +// Usage +const email = new EmailIntegration({ + host: 'smtp.gmail.com', + port: 587, + user: 'your-email@gmail.com', + pass: 'your-app-password' +}); + +// Send single note +// await email.sendNoteAsEmail('noteId', 'recipient@example.com'); + +// Send campaign +// await email.createEmailCampaign('templateNoteId', 'recipientListNoteId'); +``` + +## Best Practices + +### Error Handling + +Always wrap scripts in try-catch blocks: + +```javascript +async function safeScriptExecution() { + try { + // Your script code here + const result = await riskyOperation(); + + return { + success: true, + data: result + }; + } catch (error) { + api.log(`Error in script: ${error.message}`, 'error'); + + // Create error report note + const errorNote = await api.createTextNote( + 'root', + `Script Error - ${api.dayjs().format('YYYY-MM-DD HH:mm:ss')}`, + ` +

Script Error

+

Error: ${error.message}

+

Stack:

+
${error.stack}
+

Script: ${api.currentNote.title}

+ ` + ); + + await errorNote.setLabel('scriptError'); + + return { + success: false, + error: error.message + }; + } +} + +return await safeScriptExecution(); +``` + +### Performance Optimization + +Use batch operations and caching: + +```javascript +class OptimizedNoteProcessor { + constructor() { + this.cache = new Map(); + } + + async processNotes(noteIds) { + // Batch fetch notes + const notes = await Promise.all( + noteIds.map(id => this.getCachedNote(id)) + ); + + // Process in chunks to avoid memory issues + const chunkSize = 100; + const results = []; + + for (let i = 0; i < notes.length; i += chunkSize) { + const chunk = notes.slice(i, i + chunkSize); + const chunkResults = await Promise.all( + chunk.map(note => this.processNote(note)) + ); + results.push(...chunkResults); + + // Allow other operations + await new Promise(resolve => setTimeout(resolve, 10)); + } + + return results; + } + + async getCachedNote(noteId) { + if (!this.cache.has(noteId)) { + const note = await api.getNote(noteId); + this.cache.set(noteId, note); + } + return this.cache.get(noteId); + } + + async processNote(note) { + // Process individual note + return { + noteId: note.noteId, + processed: true + }; + } +} +``` + +### Script Organization + +Organize complex scripts with modules: + +```javascript +// Create a utility module note +const utilsNote = await api.createCodeNote('root', 'Script Utils', ` + module.exports = { + formatDate: (date) => api.dayjs(date).format('YYYY-MM-DD'), + + sanitizeHtml: (html) => { + return html + .replace(/]*>.*?<\/script>/gi, '') + .replace(/on\w+="[^"]*"/gi, ''); + }, + + async createBackup(name) { + await api.backupDatabase(name); + api.log(\`Backup created: \\${name}\`); + } + }; +`, 'js'); + +await utilsNote.setLabel('scriptModule'); +await utilsNote.setLabel('moduleName', 'utils'); + +// Use in another script +const utils = await api.requireModule('utils'); +const formattedDate = utils.formatDate(new Date()); +``` + +### Testing Scripts + +Create test suites for your scripts: + +```javascript +class ScriptTester { + constructor(scriptName) { + this.scriptName = scriptName; + this.tests = []; + this.results = []; + } + + test(description, testFn) { + this.tests.push({ description, testFn }); + } + + async run() { + api.log(`Running tests for ${this.scriptName}`); + + for (const test of this.tests) { + try { + await test.testFn(); + this.results.push({ + description: test.description, + passed: true + }); + api.log(`✅ ${test.description}`); + } catch (error) { + this.results.push({ + description: test.description, + passed: false, + error: error.message + }); + api.log(`❌ ${test.description}: ${error.message}`); + } + } + + return this.generateReport(); + } + + generateReport() { + const passed = this.results.filter(r => r.passed).length; + const failed = this.results.filter(r => !r.passed).length; + + return { + script: this.scriptName, + total: this.results.length, + passed, + failed, + results: this.results + }; + } + + assert(condition, message) { + if (!condition) { + throw new Error(message || 'Assertion failed'); + } + } + + assertEquals(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } + } +} + +// Example test suite +const tester = new ScriptTester('Note Utils'); + +tester.test('Create note', async () => { + const note = await api.createTextNote('root', 'Test Note', 'Content'); + tester.assert(note !== null, 'Note should be created'); + tester.assertEquals(note.title, 'Test Note', 'Title should match'); + + // Clean up + await note.delete(); +}); + +tester.test('Search notes', async () => { + const results = await api.searchForNotes('test'); + tester.assert(Array.isArray(results), 'Results should be an array'); +}); + +const report = await tester.run(); +return report; +``` + +## Conclusion + +The Script API provides powerful capabilities for automating and extending Trilium Notes. Key takeaways: + +1. **Use Backend Scripts** for data processing, automation, and integrations +2. **Use Frontend Scripts** for UI enhancements and user interactions +3. **Always handle errors** gracefully and provide meaningful feedback +4. **Optimize performance** with caching and batch operations +5. **Organize complex scripts** into modules for reusability +6. **Test your scripts** to ensure reliability + +For more information: + +* [Backend Script API Reference](https://triliumnext.github.io/Docs/api/Backend_Script_API.html) +* [Frontend Script API Reference](https://triliumnext.github.io/Docs/api/Frontend_Script_API.html) +* [Custom Widget Development](#root/CXtjbrjXfIlk) \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/API Documentation/WebSocket API.md b/docs/Developer Guide/Developer Guide/API Documentation/WebSocket API.md new file mode 100644 index 0000000000..58f5b84a71 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/WebSocket API.md @@ -0,0 +1,1795 @@ +# WebSocket API +## WebSocket API Documentation + +## Table of Contents + +1. [Introduction](#introduction) +2. [Connection Setup](#connection-setup) +3. [Authentication](#authentication) +4. [Message Format](#message-format) +5. [Event Types](#event-types) +6. [Real-time Synchronization](#real-time-synchronization) +7. [Custom Event Broadcasting](#custom-event-broadcasting) +8. [Client Implementation Examples](#client-implementation-examples) +9. [Debugging WebSocket Connections](#debugging-websocket-connections) +10. [Best Practices](#best-practices) +11. [Error Handling](#error-handling) +12. [Performance Optimization](#performance-optimization) + +## Introduction + +The Trilium WebSocket API provides real-time bidirectional communication between the server and clients. It's primarily used for: + +* **Real-time synchronization** of note changes across multiple clients +* **Live collaboration** features +* **Push notifications** for events +* **Streaming updates** for long-running operations +* **Frontend script execution** from backend + +### Key Features + +* Automatic reconnection with exponential backoff +* Message queuing during disconnection +* Event-based architecture +* Support for custom event types +* Built-in heartbeat/ping mechanism + +### WebSocket URL + +``` +ws://localhost:8080 // Local development +wss://your-server.com // Production with SSL +``` + +## Connection Setup + +### Basic Connection + +```javascript +// JavaScript - Basic WebSocket connection +const ws = new WebSocket('ws://localhost:8080'); + +ws.onopen = (event) => { + console.log('Connected to Trilium WebSocket'); +}; + +ws.onmessage = (event) => { + const message = JSON.parse(event.data); + console.log('Received:', message); +}; + +ws.onerror = (error) => { + console.error('WebSocket error:', error); +}; + +ws.onclose = (event) => { + console.log('Disconnected from WebSocket'); +}; +``` + +### Advanced Connection Manager + +```javascript +class TriliumWebSocketManager { + constructor(url, options = {}) { + this.url = url; + this.options = { + reconnectInterval: 5000, + maxReconnectInterval: 30000, + reconnectDecay: 1.5, + timeoutInterval: 2000, + maxReconnectAttempts: null, + ...options + }; + + this.ws = null; + this.forcedClose = false; + this.reconnectAttempts = 0; + this.messageQueue = []; + this.eventHandlers = new Map(); + this.reconnectTimer = null; + this.pingTimer = null; + } + + connect() { + this.ws = new WebSocket(this.url); + + this.ws.onopen = (event) => { + console.log('WebSocket connected'); + this.onOpen(event); + }; + + this.ws.onmessage = (event) => { + this.onMessage(event); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + this.onError(error); + }; + + this.ws.onclose = (event) => { + console.log('WebSocket closed'); + this.onClose(event); + }; + } + + onOpen(event) { + this.reconnectAttempts = 0; + + // Send queued messages + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + this.send(message); + } + + // Start ping timer + this.startPing(); + + // Emit open event + this.emit('open', event); + } + + onMessage(event) { + try { + const message = JSON.parse(event.data); + + // Handle different message types + if (message.type === 'pong') { + this.handlePong(message); + } else { + this.emit('message', message); + + // Emit specific event type + if (message.type) { + this.emit(message.type, message.data || message); + } + } + } catch (error) { + console.error('Failed to parse message:', error); + } + } + + onError(error) { + this.emit('error', error); + } + + onClose(event) { + this.ws = null; + + if (!this.forcedClose) { + this.reconnect(); + } + + this.stopPing(); + this.emit('close', event); + } + + reconnect() { + if (this.options.maxReconnectAttempts && + this.reconnectAttempts >= this.options.maxReconnectAttempts) { + this.emit('max-reconnects'); + return; + } + + this.reconnectAttempts++; + + const timeout = Math.min( + this.options.reconnectInterval * Math.pow( + this.options.reconnectDecay, + this.reconnectAttempts - 1 + ), + this.options.maxReconnectInterval + ); + + console.log(`Reconnecting in ${timeout}ms (attempt ${this.reconnectAttempts})`); + + this.reconnectTimer = setTimeout(() => { + console.log('Reconnecting...'); + this.connect(); + }, timeout); + + this.emit('reconnecting', { + attempt: this.reconnectAttempts, + timeout + }); + } + + send(data) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const message = typeof data === 'string' ? data : JSON.stringify(data); + this.ws.send(message); + } else { + // Queue message for later + this.messageQueue.push(data); + } + } + + startPing() { + this.pingTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.send({ type: 'ping', timestamp: Date.now() }); + } + }, 30000); // Ping every 30 seconds + } + + stopPing() { + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + } + + handlePong(message) { + const latency = Date.now() - message.timestamp; + this.emit('latency', latency); + } + + on(event, handler) { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, []); + } + this.eventHandlers.get(event).push(handler); + } + + off(event, handler) { + const handlers = this.eventHandlers.get(event); + if (handlers) { + const index = handlers.indexOf(handler); + if (index !== -1) { + handlers.splice(index, 1); + } + } + } + + emit(event, data) { + const handlers = this.eventHandlers.get(event); + if (handlers) { + handlers.forEach(handler => { + try { + handler(data); + } catch (error) { + console.error(`Error in event handler for ${event}:`, error); + } + }); + } + } + + close() { + this.forcedClose = true; + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.ws) { + this.ws.close(); + } + + this.stopPing(); + } + + getState() { + if (!this.ws) { + return 'DISCONNECTED'; + } + + switch (this.ws.readyState) { + case WebSocket.CONNECTING: + return 'CONNECTING'; + case WebSocket.OPEN: + return 'CONNECTED'; + case WebSocket.CLOSING: + return 'CLOSING'; + case WebSocket.CLOSED: + return 'DISCONNECTED'; + default: + return 'UNKNOWN'; + } + } +} +``` + +## Authentication + +WebSocket connections inherit authentication from the HTTP session or require token-based auth. + +### Session-Based Authentication + +```javascript +// Session auth (cookies must be included) +const ws = new WebSocket('ws://localhost:8080', { + headers: { + 'Cookie': document.cookie // Include session cookie + } +}); +``` + +### Token-Based Authentication + +```javascript +// Send auth token after connection +class AuthenticatedWebSocket { + constructor(url, token) { + this.url = url; + this.token = token; + this.authenticated = false; + } + + connect() { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + // Send authentication message + this.send({ + type: 'auth', + token: this.token + }); + }; + + this.ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.type === 'auth-success') { + this.authenticated = true; + this.onAuthenticated(); + } else if (message.type === 'auth-error') { + this.onAuthError(message.error); + } else if (this.authenticated) { + this.handleMessage(message); + } + }; + } + + send(data) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } + } + + onAuthenticated() { + console.log('WebSocket authenticated'); + } + + onAuthError(error) { + console.error('Authentication failed:', error); + } + + handleMessage(message) { + // Handle authenticated messages + } +} +``` + +## Message Format + +### Standard Message Structure + +```typescript +interface WebSocketMessage { + type: string; // Message type identifier + data?: any; // Message payload + timestamp?: number; // Unix timestamp + id?: string; // Message ID for tracking + error?: string; // Error message if applicable +} +``` + +### Common Message Types + +```javascript +// Incoming messages from server +const incomingMessages = { + // Synchronization + 'sync': { + type: 'sync', + data: { + entityChanges: [], + lastSyncedPush: 12345 + } + }, + + // Entity changes + 'entity-changes': { + type: 'entity-changes', + data: [ + { + entityName: 'notes', + entityId: 'noteId123', + action: 'update', + entity: { /* note data */ } + } + ] + }, + + // Note events + 'note-created': { + type: 'note-created', + data: { + noteId: 'newNoteId', + title: 'New Note', + parentNoteId: 'parentId' + } + }, + + 'note-updated': { + type: 'note-updated', + data: { + noteId: 'noteId123', + changes: { title: 'Updated Title' } + } + }, + + 'note-deleted': { + type: 'note-deleted', + data: { + noteId: 'deletedNoteId' + } + }, + + // Tree structure changes + 'refresh-tree': { + type: 'refresh-tree', + data: { + noteId: 'affectedNoteId' + } + }, + + // Script execution + 'frontend-script': { + type: 'frontend-script', + data: { + script: 'console.log("Hello from backend")', + params: { key: 'value' } + } + }, + + // Progress updates + 'progress-update': { + type: 'progress-update', + data: { + taskId: 'task123', + progress: 75, + message: 'Processing...' + } + }, + + // LLM streaming + 'llm-stream': { + type: 'llm-stream', + chatNoteId: 'chatNote123', + content: 'Streaming response...', + done: false + } +}; + +// Outgoing messages to server +const outgoingMessages = { + // Keep-alive ping + 'ping': { + type: 'ping', + timestamp: Date.now() + }, + + // Client logging + 'log-error': { + type: 'log-error', + error: 'Error message', + stack: 'Stack trace' + }, + + 'log-info': { + type: 'log-info', + info: 'Information message' + }, + + // Custom events + 'custom-event': { + type: 'custom-event', + data: { + eventName: 'user-action', + payload: { /* custom data */ } + } + } +}; +``` + +## Event Types + +### System Events + +```javascript +class TriliumEventHandler { + constructor(wsManager) { + this.wsManager = wsManager; + this.setupEventHandlers(); + } + + setupEventHandlers() { + // Connection events + this.wsManager.on('open', () => { + console.log('Connected to Trilium'); + this.onConnect(); + }); + + this.wsManager.on('close', () => { + console.log('Disconnected from Trilium'); + this.onDisconnect(); + }); + + this.wsManager.on('error', (error) => { + console.error('WebSocket error:', error); + this.onError(error); + }); + + this.wsManager.on('reconnecting', (info) => { + console.log(`Reconnecting... Attempt ${info.attempt}`); + this.onReconnecting(info); + }); + + // Trilium-specific events + this.wsManager.on('sync', (data) => { + this.handleSync(data); + }); + + this.wsManager.on('entity-changes', (changes) => { + this.handleEntityChanges(changes); + }); + + this.wsManager.on('note-created', (note) => { + this.handleNoteCreated(note); + }); + + this.wsManager.on('note-updated', (update) => { + this.handleNoteUpdated(update); + }); + + this.wsManager.on('note-deleted', (deletion) => { + this.handleNoteDeleted(deletion); + }); + + this.wsManager.on('refresh-tree', (data) => { + this.handleTreeRefresh(data); + }); + } + + onConnect() { + // Update UI to show connected status + this.updateConnectionStatus('connected'); + } + + onDisconnect() { + // Update UI to show disconnected status + this.updateConnectionStatus('disconnected'); + } + + onError(error) { + // Handle error + this.showError(error.message); + } + + onReconnecting(info) { + // Show reconnection status + this.updateConnectionStatus(`reconnecting (${info.attempt})`); + } + + handleSync(data) { + console.log('Sync data received:', data); + // Process synchronization data + if (data.entityChanges && data.entityChanges.length > 0) { + this.processSyncChanges(data.entityChanges); + } + } + + handleEntityChanges(changes) { + console.log('Entity changes:', changes); + + changes.forEach(change => { + switch (change.entityName) { + case 'notes': + this.processNoteChange(change); + break; + case 'branches': + this.processBranchChange(change); + break; + case 'attributes': + this.processAttributeChange(change); + break; + } + }); + } + + handleNoteCreated(note) { + console.log('Note created:', note); + // Update local cache + this.addNoteToCache(note); + // Update UI + this.addNoteToTree(note); + } + + handleNoteUpdated(update) { + console.log('Note updated:', update); + // Update local cache + this.updateNoteInCache(update.noteId, update.changes); + // Update UI if note is visible + if (this.isNoteVisible(update.noteId)) { + this.refreshNoteDisplay(update.noteId); + } + } + + handleNoteDeleted(deletion) { + console.log('Note deleted:', deletion); + // Remove from cache + this.removeNoteFromCache(deletion.noteId); + // Update UI + this.removeNoteFromTree(deletion.noteId); + } + + handleTreeRefresh(data) { + console.log('Tree refresh requested:', data); + // Refresh tree structure + this.refreshTreeBranch(data.noteId); + } + + // Placeholder methods for UI updates + updateConnectionStatus(status) { /* ... */ } + showError(message) { /* ... */ } + processSyncChanges(changes) { /* ... */ } + processNoteChange(change) { /* ... */ } + processBranchChange(change) { /* ... */ } + processAttributeChange(change) { /* ... */ } + addNoteToCache(note) { /* ... */ } + addNoteToTree(note) { /* ... */ } + updateNoteInCache(noteId, changes) { /* ... */ } + isNoteVisible(noteId) { /* ... */ } + refreshNoteDisplay(noteId) { /* ... */ } + removeNoteFromCache(noteId) { /* ... */ } + removeNoteFromTree(noteId) { /* ... */ } + refreshTreeBranch(noteId) { /* ... */ } +} +``` + +## Real-time Synchronization + +### Sync Protocol Implementation + +```javascript +class TriliumSyncManager { + constructor(wsManager) { + this.wsManager = wsManager; + this.lastSyncedPush = null; + this.pendingChanges = []; + this.syncInProgress = false; + + this.setupSyncHandlers(); + } + + setupSyncHandlers() { + this.wsManager.on('sync', (data) => { + this.handleIncomingSync(data); + }); + + this.wsManager.on('sync-complete', (data) => { + this.onSyncComplete(data); + }); + + this.wsManager.on('sync-error', (error) => { + this.onSyncError(error); + }); + } + + async handleIncomingSync(syncData) { + console.log('Processing sync data:', syncData); + + this.syncInProgress = true; + + try { + // Process entity changes in order + for (const change of syncData.entityChanges) { + await this.processEntityChange(change); + } + + // Update sync position + this.lastSyncedPush = syncData.lastSyncedPush; + + // Send acknowledgment + this.wsManager.send({ + type: 'sync-ack', + lastSyncedPush: this.lastSyncedPush + }); + + } catch (error) { + console.error('Sync processing error:', error); + this.wsManager.send({ + type: 'sync-error', + error: error.message, + lastSyncedPush: this.lastSyncedPush + }); + } finally { + this.syncInProgress = false; + this.processPendingChanges(); + } + } + + async processEntityChange(change) { + const { entityName, entityId, action, entity } = change; + + console.log(`Processing ${action} for ${entityName}:${entityId}`); + + switch (entityName) { + case 'notes': + await this.processNoteChange(action, entityId, entity); + break; + case 'branches': + await this.processBranchChange(action, entityId, entity); + break; + case 'attributes': + await this.processAttributeChange(action, entityId, entity); + break; + case 'note_contents': + await this.processContentChange(action, entityId, entity); + break; + } + } + + async processNoteChange(action, noteId, noteData) { + switch (action) { + case 'create': + await this.createNote(noteId, noteData); + break; + case 'update': + await this.updateNote(noteId, noteData); + break; + case 'delete': + await this.deleteNote(noteId); + break; + } + } + + async createNote(noteId, noteData) { + // Add to local database/cache + await localDB.notes.add({ + ...noteData, + noteId, + syncVersion: this.lastSyncedPush + }); + + // Emit event for UI update + this.emit('note-created', { noteId, noteData }); + } + + async updateNote(noteId, updates) { + // Update local database/cache + await localDB.notes.update(noteId, { + ...updates, + syncVersion: this.lastSyncedPush + }); + + // Emit event for UI update + this.emit('note-updated', { noteId, updates }); + } + + async deleteNote(noteId) { + // Remove from local database/cache + await localDB.notes.delete(noteId); + + // Emit event for UI update + this.emit('note-deleted', { noteId }); + } + + // Send local changes to server + async pushLocalChanges() { + if (this.syncInProgress) { + return; + } + + const localChanges = await this.getLocalChanges(); + + if (localChanges.length === 0) { + return; + } + + this.wsManager.send({ + type: 'push-changes', + changes: localChanges, + lastSyncedPull: this.lastSyncedPull + }); + } + + async getLocalChanges() { + // Get changes from local database that haven't been synced + const changes = await localDB.changes + .where('syncVersion') + .above(this.lastSyncedPush || 0) + .toArray(); + + return changes; + } + + processPendingChanges() { + if (this.pendingChanges.length > 0 && !this.syncInProgress) { + const changes = this.pendingChanges.splice(0); + this.handleIncomingSync({ entityChanges: changes }); + } + } + + emit(event, data) { + // Emit events to application + window.dispatchEvent(new CustomEvent(`trilium:${event}`, { detail: data })); + } +} +``` + +### Conflict Resolution + +```javascript +class ConflictResolver { + constructor(syncManager) { + this.syncManager = syncManager; + } + + async resolveConflict(localEntity, remoteEntity) { + // Compare timestamps + const localTime = new Date(localEntity.utcDateModified).getTime(); + const remoteTime = new Date(remoteEntity.utcDateModified).getTime(); + + if (localTime === remoteTime) { + // Same timestamp, compare content + return this.resolveByContent(localEntity, remoteEntity); + } + + // Default: last-write-wins + if (remoteTime > localTime) { + return { + winner: 'remote', + entity: remoteEntity, + backup: localEntity + }; + } else { + return { + winner: 'local', + entity: localEntity, + backup: remoteEntity + }; + } + } + + resolveByContent(localEntity, remoteEntity) { + // Create three-way merge if possible + const baseEntity = this.getBaseEntity(localEntity.entityId); + + if (baseEntity) { + return this.threeWayMerge(baseEntity, localEntity, remoteEntity); + } + + // Fall back to manual resolution + return this.promptUserResolution(localEntity, remoteEntity); + } + + threeWayMerge(base, local, remote) { + // Implement three-way merge logic + const merged = { ...base }; + + // Merge each property + for (const key in local) { + if (local[key] !== base[key] && remote[key] !== base[key]) { + // Both changed - conflict + if (local[key] === remote[key]) { + // Same change + merged[key] = local[key]; + } else { + // Different changes - need resolution + merged[key] = this.mergeProperty(key, base[key], local[key], remote[key]); + } + } else if (local[key] !== base[key]) { + // Only local changed + merged[key] = local[key]; + } else if (remote[key] !== base[key]) { + // Only remote changed + merged[key] = remote[key]; + } + } + + return { + winner: 'merged', + entity: merged, + localChanges: this.diff(base, local), + remoteChanges: this.diff(base, remote) + }; + } + + mergeProperty(key, base, local, remote) { + // Property-specific merge strategies + switch (key) { + case 'content': + // For content, try text merge + return this.mergeText(base, local, remote); + case 'attributes': + // For attributes, merge arrays + return this.mergeArrays(base, local, remote); + default: + // Default to remote for safety + return remote; + } + } + + async promptUserResolution(local, remote) { + // Show conflict resolution UI + const resolution = await this.showConflictDialog({ + local, + remote, + diff: this.diff(local, remote) + }); + + return resolution; + } + + diff(obj1, obj2) { + const changes = {}; + + for (const key in obj2) { + if (obj1[key] !== obj2[key]) { + changes[key] = { + old: obj1[key], + new: obj2[key] + }; + } + } + + return changes; + } +} +``` + +## Custom Event Broadcasting + +### Creating Custom Events + +```javascript +class CustomEventBroadcaster { + constructor(wsManager) { + this.wsManager = wsManager; + this.eventListeners = new Map(); + } + + // Broadcast event to all connected clients + broadcast(eventName, data) { + this.wsManager.send({ + type: 'custom-broadcast', + eventName, + data, + timestamp: Date.now() + }); + } + + // Send event to specific clients + sendToClients(clientIds, eventName, data) { + this.wsManager.send({ + type: 'targeted-broadcast', + targets: clientIds, + eventName, + data, + timestamp: Date.now() + }); + } + + // Subscribe to custom events + subscribe(eventName, handler) { + if (!this.eventListeners.has(eventName)) { + this.eventListeners.set(eventName, []); + } + + this.eventListeners.get(eventName).push(handler); + + // Register with server + this.wsManager.send({ + type: 'subscribe', + eventName + }); + } + + // Unsubscribe from events + unsubscribe(eventName, handler) { + const handlers = this.eventListeners.get(eventName); + if (handlers) { + const index = handlers.indexOf(handler); + if (index !== -1) { + handlers.splice(index, 1); + } + + if (handlers.length === 0) { + this.eventListeners.delete(eventName); + + // Unregister with server + this.wsManager.send({ + type: 'unsubscribe', + eventName + }); + } + } + } + + // Handle incoming custom events + handleCustomEvent(message) { + const { eventName, data } = message; + const handlers = this.eventListeners.get(eventName); + + if (handlers) { + handlers.forEach(handler => { + try { + handler(data); + } catch (error) { + console.error(`Error handling custom event ${eventName}:`, error); + } + }); + } + } +} + +// Usage example +const broadcaster = new CustomEventBroadcaster(wsManager); + +// Subscribe to custom events +broadcaster.subscribe('user-joined', (data) => { + console.log(`User ${data.username} joined`); +}); + +broadcaster.subscribe('collaborative-edit', (data) => { + console.log(`Edit on note ${data.noteId}: ${data.change}`); +}); + +// Broadcast custom event +broadcaster.broadcast('user-action', { + action: 'viewed-note', + noteId: 'abc123', + userId: 'user456' +}); +``` + +### Collaborative Features + +```javascript +class CollaborationManager { + constructor(wsManager, userId) { + this.wsManager = wsManager; + this.userId = userId; + this.activeSessions = new Map(); + this.cursorPositions = new Map(); + + this.setupCollaborationHandlers(); + } + + setupCollaborationHandlers() { + this.wsManager.on('collab-session-started', (data) => { + this.handleSessionStarted(data); + }); + + this.wsManager.on('collab-user-joined', (data) => { + this.handleUserJoined(data); + }); + + this.wsManager.on('collab-user-left', (data) => { + this.handleUserLeft(data); + }); + + this.wsManager.on('collab-cursor-update', (data) => { + this.handleCursorUpdate(data); + }); + + this.wsManager.on('collab-selection-update', (data) => { + this.handleSelectionUpdate(data); + }); + + this.wsManager.on('collab-content-change', (data) => { + this.handleContentChange(data); + }); + } + + startCollaborationSession(noteId) { + this.wsManager.send({ + type: 'start-collab-session', + noteId, + userId: this.userId + }); + + const session = { + noteId, + users: new Set([this.userId]), + startTime: Date.now() + }; + + this.activeSessions.set(noteId, session); + + return session; + } + + joinCollaborationSession(noteId) { + this.wsManager.send({ + type: 'join-collab-session', + noteId, + userId: this.userId + }); + } + + leaveCollaborationSession(noteId) { + this.wsManager.send({ + type: 'leave-collab-session', + noteId, + userId: this.userId + }); + + this.activeSessions.delete(noteId); + } + + sendCursorPosition(noteId, position) { + this.wsManager.send({ + type: 'collab-cursor-update', + noteId, + userId: this.userId, + position + }); + } + + sendSelectionUpdate(noteId, selection) { + this.wsManager.send({ + type: 'collab-selection-update', + noteId, + userId: this.userId, + selection + }); + } + + sendContentChange(noteId, change) { + this.wsManager.send({ + type: 'collab-content-change', + noteId, + userId: this.userId, + change + }); + } + + handleSessionStarted(data) { + const { noteId, users } = data; + + const session = { + noteId, + users: new Set(users), + startTime: Date.now() + }; + + this.activeSessions.set(noteId, session); + + // Update UI to show collaboration indicators + this.showCollaborationIndicator(noteId, users); + } + + handleUserJoined(data) { + const { noteId, userId, userInfo } = data; + const session = this.activeSessions.get(noteId); + + if (session) { + session.users.add(userId); + this.showUserJoinedNotification(userInfo); + } + } + + handleUserLeft(data) { + const { noteId, userId } = data; + const session = this.activeSessions.get(noteId); + + if (session) { + session.users.delete(userId); + this.removeUserCursor(userId); + } + } + + handleCursorUpdate(data) { + const { userId, position } = data; + + if (userId !== this.userId) { + this.cursorPositions.set(userId, position); + this.updateUserCursor(userId, position); + } + } + + handleSelectionUpdate(data) { + const { userId, selection } = data; + + if (userId !== this.userId) { + this.updateUserSelection(userId, selection); + } + } + + handleContentChange(data) { + const { noteId, userId, change } = data; + + if (userId !== this.userId) { + this.applyRemoteChange(noteId, change); + } + } + + // UI update methods (implement based on your UI framework) + showCollaborationIndicator(noteId, users) { /* ... */ } + showUserJoinedNotification(userInfo) { /* ... */ } + removeUserCursor(userId) { /* ... */ } + updateUserCursor(userId, position) { /* ... */ } + updateUserSelection(userId, selection) { /* ... */ } + applyRemoteChange(noteId, change) { /* ... */ } +} +``` + +## Client Implementation Examples + +### React Hook + +``` +// useWebSocket.js +import { useEffect, useRef, useState, useCallback } from 'react'; + +export function useTriliumWebSocket(url, options = {}) { + const [isConnected, setIsConnected] = useState(false); + const [lastMessage, setLastMessage] = useState(null); + const [error, setError] = useState(null); + + const wsManager = useRef(null); + const messageHandlers = useRef(new Map()); + + useEffect(() => { + wsManager.current = new TriliumWebSocketManager(url, options); + + wsManager.current.on('open', () => { + setIsConnected(true); + setError(null); + }); + + wsManager.current.on('close', () => { + setIsConnected(false); + }); + + wsManager.current.on('error', (err) => { + setError(err); + }); + + wsManager.current.on('message', (msg) => { + setLastMessage(msg); + + // Call registered handlers + const handler = messageHandlers.current.get(msg.type); + if (handler) { + handler(msg.data || msg); + } + }); + + wsManager.current.connect(); + + return () => { + wsManager.current.close(); + }; + }, [url]); + + const sendMessage = useCallback((message) => { + if (wsManager.current) { + wsManager.current.send(message); + } + }, []); + + const subscribe = useCallback((messageType, handler) => { + messageHandlers.current.set(messageType, handler); + + return () => { + messageHandlers.current.delete(messageType); + }; + }, []); + + return { + isConnected, + lastMessage, + error, + sendMessage, + subscribe + }; +} + +// Usage in React component +function TriliumNoteEditor({ noteId }) { + const { isConnected, sendMessage, subscribe } = useTriliumWebSocket( + 'ws://localhost:8080' + ); + + const [content, setContent] = useState(''); + + useEffect(() => { + // Subscribe to note updates + const unsubscribe = subscribe('note-updated', (data) => { + if (data.noteId === noteId) { + setContent(data.content); + } + }); + + return unsubscribe; + }, [noteId, subscribe]); + + const handleContentChange = (newContent) => { + setContent(newContent); + + // Send update via WebSocket + sendMessage({ + type: 'update-note', + noteId, + content: newContent + }); + }; + + return ( +
+
Connection: {isConnected ? '🟢' : '🔴'}
+ +
+
+ + + `); + + this.setupStyles(); + this.bindEvents(); + } + + setupStyles() { + this.cssBlock(` + .markdown-type-widget { + height: 100%; + display: flex; + flex-direction: column; + } + + .markdown-toolbar { + padding: 10px; + border-bottom: 1px solid var(--main-border-color); + display: flex; + gap: 10px; + } + + .markdown-container { + flex: 1; + display: flex; + overflow: hidden; + } + + .markdown-editor, + .markdown-preview { + flex: 1; + padding: 20px; + overflow-y: auto; + } + + .markdown-editor { + border-right: 1px solid var(--main-border-color); + } + + .markdown-input { + width: 100%; + height: 100%; + border: none; + outline: none; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 14px; + line-height: 1.6; + resize: none; + background: var(--main-background-color); + color: var(--main-text-color); + } + + .markdown-preview { + background: var(--main-background-color); + } + + .markdown-preview h1 { + font-size: 2em; + margin: 0.67em 0; + border-bottom: 1px solid var(--main-border-color); + padding-bottom: 0.3em; + } + + .markdown-preview h2 { + font-size: 1.5em; + margin: 0.75em 0; + border-bottom: 1px solid var(--main-border-color); + padding-bottom: 0.3em; + } + + .markdown-preview code { + background: var(--code-background-color); + padding: 2px 4px; + border-radius: 3px; + font-family: 'Monaco', 'Courier New', monospace; + } + + .markdown-preview pre { + background: var(--code-background-color); + padding: 16px; + border-radius: 6px; + overflow-x: auto; + } + + .markdown-preview blockquote { + border-left: 4px solid var(--primary-color); + margin: 0; + padding-left: 16px; + color: var(--muted-text-color); + } + + .markdown-preview table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; + } + + .markdown-preview th, + .markdown-preview td { + border: 1px solid var(--main-border-color); + padding: 8px 12px; + } + + .markdown-preview th { + background: var(--button-background-color); + font-weight: 600; + } + + .markdown-type-widget.preview-only .markdown-editor { + display: none; + } + + .markdown-type-widget.preview-only .markdown-preview { + border-right: none; + } + + .markdown-type-widget.edit-only .markdown-preview { + display: none; + } + + .markdown-type-widget.edit-only .markdown-editor { + border-right: none; + } + `); + } + + bindEvents() { + const $input = this.$widget.find('.markdown-input'); + const $preview = this.$widget.find('.markdown-preview'); + + // Text input handler + $input.on('input', () => { + const content = $input.val() as string; + this.updatePreview(content); + this.spacedUpdate.scheduleUpdate(); + }); + + // Toolbar buttons + this.$widget.find('.toggle-edit').on('click', () => { + this.$widget.toggleClass('edit-only'); + this.$widget.removeClass('preview-only'); + }); + + this.$widget.find('.toggle-preview').on('click', () => { + this.$widget.toggleClass('preview-only'); + this.$widget.removeClass('edit-only'); + }); + + this.$widget.find('.export-html').on('click', () => { + this.exportAsHtml(); + }); + + // Keyboard shortcuts + $input.on('keydown', (e) => { + this.handleKeyboard(e); + }); + } + + handleKeyboard(e: JQuery.KeyDownEvent) { + const $input = $(e.target); + + // Tab handling for lists + if (e.key === 'Tab') { + e.preventDefault(); + const start = ($input[0] as HTMLTextAreaElement).selectionStart; + const end = ($input[0] as HTMLTextAreaElement).selectionEnd; + const value = $input.val() as string; + + $input.val(value.substring(0, start) + ' ' + value.substring(end)); + ($input[0] as HTMLTextAreaElement).selectionStart = + ($input[0] as HTMLTextAreaElement).selectionEnd = start + 4; + } + + // Bold shortcut (Ctrl+B) + if (e.ctrlKey && e.key === 'b') { + e.preventDefault(); + this.wrapSelection('**', '**'); + } + + // Italic shortcut (Ctrl+I) + if (e.ctrlKey && e.key === 'i') { + e.preventDefault(); + this.wrapSelection('*', '*'); + } + + // Link shortcut (Ctrl+K) + if (e.ctrlKey && e.key === 'k') { + e.preventDefault(); + this.insertLink(); + } + } + + wrapSelection(before: string, after: string) { + const $input = this.$widget.find('.markdown-input'); + const textarea = $input[0] as HTMLTextAreaElement; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const value = $input.val() as string; + const selection = value.substring(start, end); + + const newValue = value.substring(0, start) + + before + selection + after + + value.substring(end); + + $input.val(newValue); + textarea.selectionStart = start + before.length; + textarea.selectionEnd = end + before.length; + + $input.trigger('input'); + } + + async insertLink() { + const url = prompt('Enter URL:'); + if (url) { + const $input = this.$widget.find('.markdown-input'); + const textarea = $input[0] as HTMLTextAreaElement; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const value = $input.val() as string; + const selection = value.substring(start, end) || 'link text'; + + const link = `[${selection}](${url})`; + const newValue = value.substring(0, start) + link + value.substring(end); + + $input.val(newValue); + $input.trigger('input'); + } + } + + updatePreview(content: string) { + // Configure marked options + marked.setOptions({ + breaks: true, + gfm: true, + tables: true, + sanitize: false, + smartLists: true, + smartypants: true, + highlight: (code, lang) => { + // Add syntax highlighting if available + if (window.hljs && lang && window.hljs.getLanguage(lang)) { + return window.hljs.highlight(code, { language: lang }).value; + } + return code; + } + }); + + // Convert markdown to HTML + const html = marked.parse(content); + + // Update preview + this.$widget.find('.markdown-preview').html(html); + + // Process internal links + this.processInternalLinks(); + } + + processInternalLinks() { + this.$widget.find('.markdown-preview a').each((_, el) => { + const $link = $(el); + const href = $link.attr('href'); + + // Check for internal note links + if (href?.startsWith('#')) { + const noteId = href.substring(1); + $link.on('click', async (e) => { + e.preventDefault(); + const note = await froca.getNote(noteId); + if (note) { + appContext.tabManager.getActiveContext()?.setNote(noteId); + } + }); + } + }); + } + + async doRefresh(note) { + this.note = note; + const content = await this.getContent(); + + this.$widget.find('.markdown-input').val(content); + this.updatePreview(content); + + this.lastContent = content; + } + + async getContent() { + return await this.note.getContent(); + } + + async saveContent() { + const content = this.$widget.find('.markdown-input').val() as string; + + if (content === this.lastContent) { + return; // No changes + } + + try { + await server.put(`notes/${this.note.noteId}/content`, { + content: content + }); + + this.lastContent = content; + + } catch (error) { + toastService.showError('Failed to save markdown content'); + console.error('Save error:', error); + } + } + + async exportAsHtml() { + const content = this.$widget.find('.markdown-input').val() as string; + const html = marked.parse(content); + + // Create full HTML document + const fullHtml = ` + + + + + ${this.note.title} + + + + ${html} + + + `; + + // Download file + const blob = new Blob([fullHtml], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.note.title}.html`; + a.click(); + URL.revokeObjectURL(url); + + toastService.showMessage('Markdown exported as HTML'); + } + + cleanup() { + this.$widget.find('.markdown-input').off(); + this.$widget.find('button').off(); + this.spacedUpdate = null; + } +} +``` + +### Step 3: Register the Widget + +```typescript +// apps/client/src/services/note_type_registry.ts + +import MarkdownTypeWidget from "../widgets/type_widgets/markdown.js"; + +export function registerNoteTypes() { + // ... existing registrations + + noteTypeService.register(MarkdownTypeWidget); +} +``` + +### Step 4: Add Backend Support + +```typescript +// apps/server/src/services/notes.ts + +// Add to note creation +export async function createNote(params: NoteParams) { + // ... existing code + + if (params.type === 'markdown') { + // Set appropriate MIME type + params.mime = 'text/markdown'; + + // Initialize with template if needed + if (!params.content) { + params.content = '# New Markdown Note\n\nStart writing...'; + } + } + + // ... rest of creation logic +} + +// Add import support +export async function importMarkdown(filePath: string, parentNoteId: string) { + const fs = require('fs').promises; + const content = await fs.readFile(filePath, 'utf8'); + + const note = await createNote({ + parentNoteId, + title: path.basename(filePath, '.md'), + content, + type: 'markdown', + mime: 'text/markdown' + }); + + return note; +} + +// Add export support +export async function exportMarkdown(noteId: string, targetPath: string) { + const note = await becca.getNote(noteId); + const content = await note.getContent(); + + const fs = require('fs').promises; + await fs.writeFile(targetPath, content, 'utf8'); +} +``` + +## Complete Example: Markdown Preview Note Type + +Here's a full implementation of a markdown note type with live preview: + +### Widget Implementation + +```typescript +// apps/client/src/widgets/type_widgets/markdown_preview.ts + +import TypeWidget from "./type_widget.js"; +import SpacedUpdate from "../../services/spaced_update.js"; +import server from "../../services/server.js"; +import toastService from "../../services/toast.js"; +import appContext from "../../components/app_context.js"; +import froca from "../../services/froca.js"; +import linkService from "../../services/link.js"; +import utils from "../../services/utils.js"; + +interface MarkdownConfig { + splitView: boolean; + syncScroll: boolean; + showLineNumbers: boolean; + theme: 'light' | 'dark' | 'auto'; +} + +export default class MarkdownPreviewWidget extends TypeWidget { + static getType() { + return "markdownPreview"; + } + + private config: MarkdownConfig; + private editor: any; // CodeMirror instance + private spacedUpdate: SpacedUpdate; + private isRendering: boolean = false; + + constructor() { + super(); + + this.config = { + splitView: true, + syncScroll: true, + showLineNumbers: true, + theme: 'auto' + }; + + this.spacedUpdate = new SpacedUpdate(async () => { + await this.saveContent(); + }, 1000); + } + + doRender() { + this.$widget = $(` +
+
+
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+
+ +
+ + + +
+
+ +
+
+ +
+
+
+
+
+ + +
+ `); + + this.setupStyles(); + this.initializeEditor(); + this.bindEvents(); + } + + setupStyles() { + this.cssBlock(` + .markdown-preview-widget { + height: 100%; + display: flex; + flex-direction: column; + background: var(--main-background-color); + } + + .markdown-header { + border-bottom: 1px solid var(--main-border-color); + padding: 8px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; + } + + .markdown-toolbar { + display: flex; + gap: 12px; + flex-wrap: wrap; + } + + .markdown-toolbar .btn-group { + display: flex; + gap: 2px; + } + + .markdown-toolbar .btn { + padding: 4px 8px; + min-width: 32px; + } + + .markdown-view-controls { + display: flex; + gap: 2px; + } + + .markdown-content { + flex: 1; + display: flex; + overflow: hidden; + } + + .markdown-editor-container, + .markdown-preview-container { + flex: 1; + overflow: auto; + } + + .markdown-editor-container { + border-right: 1px solid var(--main-border-color); + } + + .CodeMirror { + height: 100%; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 14px; + } + + .markdown-preview { + padding: 20px; + max-width: 900px; + margin: 0 auto; + } + + /* Markdown preview styles */ + .markdown-preview h1 { + font-size: 2.5em; + margin: 0.67em 0; + padding-bottom: 0.3em; + border-bottom: 2px solid var(--main-border-color); + } + + .markdown-preview h2 { + font-size: 2em; + margin: 0.75em 0; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--main-border-color); + } + + .markdown-preview h3 { + font-size: 1.5em; + margin: 0.83em 0; + } + + .markdown-preview h4 { + font-size: 1.2em; + margin: 1em 0; + } + + .markdown-preview p { + margin: 1em 0; + line-height: 1.7; + } + + .markdown-preview code { + background: var(--code-background-color); + padding: 2px 6px; + border-radius: 3px; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 0.9em; + } + + .markdown-preview pre { + background: var(--code-background-color); + padding: 16px; + border-radius: 6px; + overflow-x: auto; + line-height: 1.45; + } + + .markdown-preview pre code { + background: none; + padding: 0; + } + + .markdown-preview blockquote { + border-left: 4px solid var(--primary-color); + margin: 1em 0; + padding: 0.5em 1em; + color: var(--muted-text-color); + background: var(--button-background-color); + } + + .markdown-preview ul, + .markdown-preview ol { + margin: 1em 0; + padding-left: 2em; + } + + .markdown-preview li { + margin: 0.5em 0; + } + + .markdown-preview table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; + } + + .markdown-preview th, + .markdown-preview td { + border: 1px solid var(--main-border-color); + padding: 8px 12px; + text-align: left; + } + + .markdown-preview th { + background: var(--button-background-color); + font-weight: 600; + } + + .markdown-preview img { + max-width: 100%; + height: auto; + display: block; + margin: 1em auto; + } + + .markdown-preview a { + color: var(--link-color); + text-decoration: none; + } + + .markdown-preview a:hover { + text-decoration: underline; + } + + .markdown-preview hr { + border: none; + border-top: 2px solid var(--main-border-color); + margin: 2em 0; + } + + .markdown-preview .task-list-item { + list-style: none; + margin-left: -1.5em; + } + + .markdown-preview .task-list-item input[type="checkbox"] { + margin-right: 0.5em; + } + + .markdown-footer { + border-top: 1px solid var(--main-border-color); + padding: 8px 16px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .markdown-stats { + display: flex; + gap: 20px; + font-size: 0.9em; + color: var(--muted-text-color); + } + + .stat-label { + margin-right: 4px; + } + + .stat-value { + font-weight: 600; + color: var(--main-text-color); + } + + /* View modes */ + .markdown-preview-widget.edit-mode .markdown-preview-container { + display: none; + } + + .markdown-preview-widget.edit-mode .markdown-editor-container { + border-right: none; + } + + .markdown-preview-widget.preview-mode .markdown-editor-container { + display: none; + } + + /* Syntax highlighting */ + .hljs { + background: var(--code-background-color); + color: var(--main-text-color); + } + `); + } + + initializeEditor() { + // Initialize CodeMirror + const textarea = this.$widget.find('.markdown-editor')[0]; + + this.editor = CodeMirror.fromTextArea(textarea, { + mode: 'markdown', + lineNumbers: this.config.showLineNumbers, + lineWrapping: true, + theme: this.getEditorTheme(), + extraKeys: { + 'Ctrl-B': () => this.insertFormatting('bold'), + 'Ctrl-I': () => this.insertFormatting('italic'), + 'Ctrl-K': () => this.insertFormatting('link'), + 'Tab': 'indentMore', + 'Shift-Tab': 'indentLess' + } + }); + + // Handle editor changes + this.editor.on('change', () => { + this.handleContentChange(); + }); + + // Sync scroll if enabled + if (this.config.syncScroll) { + this.setupScrollSync(); + } + } + + getEditorTheme() { + if (this.config.theme === 'auto') { + const isDark = $('body').hasClass('theme-dark'); + return isDark ? 'monokai' : 'default'; + } + return this.config.theme === 'dark' ? 'monokai' : 'default'; + } + + setupScrollSync() { + const editorScroll = this.$widget.find('.CodeMirror-scroll'); + const previewScroll = this.$widget.find('.markdown-preview-container'); + + let syncingScroll = false; + + editorScroll.on('scroll', () => { + if (syncingScroll) return; + syncingScroll = true; + + const percentage = editorScroll.scrollTop() / + (editorScroll[0].scrollHeight - editorScroll.height()); + + previewScroll.scrollTop( + percentage * (previewScroll[0].scrollHeight - previewScroll.height()) + ); + + setTimeout(() => syncingScroll = false, 100); + }); + + previewScroll.on('scroll', () => { + if (syncingScroll) return; + syncingScroll = true; + + const percentage = previewScroll.scrollTop() / + (previewScroll[0].scrollHeight - previewScroll.height()); + + editorScroll.scrollTop( + percentage * (editorScroll[0].scrollHeight - editorScroll.height()) + ); + + setTimeout(() => syncingScroll = false, 100); + }); + } + + bindEvents() { + // Toolbar buttons + this.$widget.on('click', '[data-action]', (e) => { + const action = $(e.currentTarget).attr('data-action'); + this.handleAction(action!); + }); + + // View mode buttons + this.$widget.on('click', '[data-view]', (e) => { + const $btn = $(e.currentTarget); + const view = $btn.attr('data-view'); + + this.$widget.find('[data-view]').removeClass('active'); + $btn.addClass('active'); + + this.$widget.removeClass('edit-mode preview-mode'); + if (view === 'edit') { + this.$widget.addClass('edit-mode'); + } else if (view === 'preview') { + this.$widget.addClass('preview-mode'); + } + }); + } + + handleContentChange() { + const content = this.editor.getValue(); + + // Update preview + this.renderPreview(content); + + // Update statistics + this.updateStatistics(content); + + // Schedule save + this.spacedUpdate.scheduleUpdate(); + } + + renderPreview(content: string) { + if (this.isRendering) return; + this.isRendering = true; + + // Use marked.js for markdown rendering + const marked = window.marked; + + marked.setOptions({ + breaks: true, + gfm: true, + tables: true, + smartLists: true, + smartypants: true, + highlight: (code, lang) => { + if (window.hljs && lang && window.hljs.getLanguage(lang)) { + try { + return window.hljs.highlight(code, { language: lang }).value; + } catch (e) { + console.error('Highlight error:', e); + } + } + return code; + } + }); + + try { + const html = marked.parse(content); + this.$widget.find('.markdown-preview').html(html); + + // Process internal links + this.processLinks(); + + // Process checkboxes + this.processCheckboxes(); + + } catch (error) { + console.error('Markdown render error:', error); + } + + this.isRendering = false; + } + + processLinks() { + this.$widget.find('.markdown-preview a').each((_, el) => { + const $link = $(el); + const href = $link.attr('href'); + + if (!href) return; + + // Internal note links (#noteId) + if (href.startsWith('#')) { + const noteId = href.substring(1); + $link.on('click', async (e) => { + e.preventDefault(); + await appContext.tabManager.getActiveContext()?.setNote(noteId); + }); + } + // External links + else if (href.startsWith('http')) { + $link.attr('target', '_blank'); + $link.attr('rel', 'noopener noreferrer'); + } + }); + } + + processCheckboxes() { + this.$widget.find('.markdown-preview input[type="checkbox"]').each((i, el) => { + const $checkbox = $(el); + const $li = $checkbox.closest('li'); + + $li.addClass('task-list-item'); + + $checkbox.on('change', () => { + const isChecked = $checkbox.is(':checked'); + this.updateTaskInEditor(i, isChecked); + }); + }); + } + + updateTaskInEditor(index: number, checked: boolean) { + const content = this.editor.getValue(); + const lines = content.split('\n'); + + let taskCount = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*[-*+]\s+\[[ x]\]/)) { + if (taskCount === index) { + lines[i] = lines[i].replace( + /\[[ x]\]/, + checked ? '[x]' : '[ ]' + ); + break; + } + taskCount++; + } + } + + this.editor.setValue(lines.join('\n')); + } + + updateStatistics(content: string) { + const words = content.match(/\b\w+\b/g)?.length || 0; + const chars = content.length; + const lines = content.split('\n').length; + + this.$widget.find('[data-stat="words"]').text(words); + this.$widget.find('[data-stat="chars"]').text(chars); + this.$widget.find('[data-stat="lines"]').text(lines); + } + + handleAction(action: string) { + switch (action) { + case 'bold': + case 'italic': + case 'strikethrough': + case 'h1': + case 'h2': + case 'h3': + case 'ul': + case 'ol': + case 'task': + case 'quote': + case 'code': + case 'codeblock': + case 'link': + case 'image': + case 'table': + case 'hr': + this.insertFormatting(action); + break; + + case 'export-html': + this.exportAsHtml(); + break; + + case 'export-pdf': + this.exportAsPdf(); + break; + } + } + + insertFormatting(type: string) { + const cursor = this.editor.getCursor(); + const selection = this.editor.getSelection(); + + const formats: Record = { + bold: { wrap: '**' }, + italic: { wrap: '*' }, + strikethrough: { wrap: '~~' }, + h1: { prefix: '# ' }, + h2: { prefix: '## ' }, + h3: { prefix: '### ' }, + ul: { prefix: '- ' }, + ol: { prefix: '1. ' }, + task: { prefix: '- [ ] ' }, + quote: { prefix: '> ' }, + code: { wrap: '`' }, + codeblock: { + before: '```\n', + after: '\n```' + }, + link: { + template: '[${text}](${url})' + }, + image: { + template: '![${alt}](${url})' + }, + table: { + template: '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |' + }, + hr: { + insert: '\n---\n' + } + }; + + const format = formats[type]; + if (!format) return; + + if (format.wrap) { + const wrapped = format.wrap + (selection || 'text') + format.wrap; + this.editor.replaceSelection(wrapped); + } else if (format.prefix) { + this.editor.setCursor({ line: cursor.line, ch: 0 }); + this.editor.replaceRange(format.prefix, cursor); + } else if (format.before && format.after) { + const text = format.before + (selection || '') + format.after; + this.editor.replaceSelection(text); + } else if (format.template) { + // Handle templates with placeholders + if (type === 'link') { + const url = prompt('Enter URL:') || ''; + const text = selection || 'link text'; + this.editor.replaceSelection(`[${text}](${url})`); + } else if (type === 'image') { + const url = prompt('Enter image URL:') || ''; + const alt = selection || 'alt text'; + this.editor.replaceSelection(`![${alt}](${url})`); + } else { + this.editor.replaceSelection(format.template); + } + } else if (format.insert) { + this.editor.replaceSelection(format.insert); + } + + this.editor.focus(); + } + + async doRefresh(note) { + this.note = note; + + const content = await note.getContent(); + this.editor.setValue(content); + + this.renderPreview(content); + this.updateStatistics(content); + } + + async saveContent() { + if (!this.note) return; + + const content = this.editor.getValue(); + + try { + await server.put(`notes/${this.note.noteId}/content`, { + content: content + }); + } catch (error) { + console.error('Save error:', error); + toastService.showError('Failed to save markdown content'); + } + } + + async exportAsHtml() { + const content = this.editor.getValue(); + const html = marked.parse(content); + + const fullHtml = ` + + + + + ${this.note.title} + + + +
+ ${html} +
+ + + `; + + const blob = new Blob([fullHtml], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.note.title}.html`; + a.click(); + URL.revokeObjectURL(url); + + toastService.showMessage('Exported as HTML'); + } + + async exportAsPdf() { + // This would require a backend service or library like jsPDF + toastService.showMessage('PDF export not yet implemented'); + } + + getExportStyles() { + return ` + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #333; + max-width: 900px; + margin: 0 auto; + padding: 20px; + } + /* ... additional export styles ... */ + `; + } + + cleanup() { + if (this.editor) { + this.editor.toTextArea(); + } + this.$widget.off('click'); + this.spacedUpdate = null; + } +} +``` + +## Advanced Features + +### Custom Import/Export + +```typescript +// apps/server/src/services/import_export/markdown_handler.ts + +import fs from 'fs/promises'; +import path from 'path'; +import matter from 'gray-matter'; + +export class MarkdownImportExport { + async importMarkdownFile(filePath: string, parentNoteId: string) { + const content = await fs.readFile(filePath, 'utf8'); + + // Parse frontmatter if present + const { data: metadata, content: body } = matter(content); + + // Create note + const note = await api.createNote( + parentNoteId, + metadata.title || path.basename(filePath, '.md'), + body + ); + + // Set note type + note.type = 'markdown'; + note.mime = 'text/markdown'; + + // Add metadata as attributes + if (metadata.tags) { + for (const tag of metadata.tags) { + await note.addLabel('tag', tag); + } + } + + if (metadata.date) { + await note.addLabel('created', metadata.date); + } + + await note.save(); + return note; + } + + async exportMarkdownFile(noteId: string, targetDir: string) { + const note = await api.getNote(noteId); + const content = await note.getContent(); + + // Build frontmatter + const metadata: any = { + title: note.title, + date: note.dateCreated, + modified: note.dateModified + }; + + // Add tags + const tags = note.getLabels() + .filter(l => l.name === 'tag') + .map(l => l.value); + + if (tags.length > 0) { + metadata.tags = tags; + } + + // Create markdown with frontmatter + const markdown = matter.stringify(content, metadata); + + // Write file + const fileName = `${note.title.replace(/[^a-z0-9]/gi, '_')}.md`; + const filePath = path.join(targetDir, fileName); + + await fs.writeFile(filePath, markdown, 'utf8'); + + return filePath; + } +} +``` + +### Custom Actions and Commands + +```typescript +// Add custom actions for the note type +class MarkdownActions { + static registerActions() { + // Register command palette actions + api.addCommand({ + name: 'markdown:togglePreview', + label: 'Markdown: Toggle Preview', + action: async () => { + const widget = api.getActiveWidget(); + if (widget instanceof MarkdownPreviewWidget) { + widget.togglePreview(); + } + } + }); + + // Register context menu items + api.addContextMenuItem({ + noteType: 'markdown', + label: 'Convert to HTML', + action: async (note) => { + await this.convertToHtml(note); + } + }); + } + + static async convertToHtml(note) { + const content = await note.getContent(); + const html = marked.parse(content); + + // Create new HTML note + const htmlNote = await api.createNote( + note.getParentNoteIds()[0], + `${note.title} (HTML)`, + html + ); + + htmlNote.type = 'text'; + htmlNote.mime = 'text/html'; + await htmlNote.save(); + + toastService.showMessage('Converted to HTML note'); + } +} +``` + +## Testing Your Note Type + +```typescript +// apps/client/test/widgets/markdown_preview.test.ts + +import MarkdownPreviewWidget from '../../src/widgets/type_widgets/markdown_preview'; + +describe('MarkdownPreviewWidget', () => { + let widget: MarkdownPreviewWidget; + let mockNote: any; + + beforeEach(() => { + widget = new MarkdownPreviewWidget(); + mockNote = { + noteId: 'test123', + title: 'Test Note', + type: 'markdown', + getContent: jest.fn().mockResolvedValue('# Test\n\nContent'), + setContent: jest.fn() + }; + }); + + test('renders markdown correctly', async () => { + widget.doRender(); + await widget.doRefresh(mockNote); + + const preview = widget.$widget.find('.markdown-preview').html(); + expect(preview).toContain('

Test

'); + expect(preview).toContain('

Content

'); + }); + + test('handles formatting shortcuts', () => { + widget.doRender(); + widget.initializeEditor(); + + // Test bold formatting + widget.editor.setValue('test'); + widget.editor.setSelection( + { line: 0, ch: 0 }, + { line: 0, ch: 4 } + ); + widget.insertFormatting('bold'); + + expect(widget.editor.getValue()).toBe('**test**'); + }); + + test('saves content on change', async () => { + jest.useFakeTimers(); + + widget.doRender(); + await widget.doRefresh(mockNote); + + // Change content + widget.editor.setValue('New content'); + + // Wait for debounce + jest.advanceTimersByTime(1100); + + expect(server.put).toHaveBeenCalledWith( + 'notes/test123/content', + { content: 'New content' } + ); + }); +}); +``` + +## Best Practices + +1. **Performance** + + * Debounce saves and preview updates + * Use virtual scrolling for large documents + * Cache rendered content when possible +2. **User Experience** + + * Provide keyboard shortcuts + * Show visual feedback for actions + * Maintain cursor position on refresh +3. **Data Integrity** + + * Validate content before saving + * Handle conflicts gracefully + * Provide undo/redo functionality +4. **Extensibility** + + * Use configuration options + * Support plugins/extensions + * Provide hooks for customization +5. **Testing** + + * Test rendering edge cases + * Verify import/export functionality + * Test keyboard shortcuts and actions + +## Troubleshooting + +### Widget Not Loading + +* Check type registration +* Verify MIME type matches +* Check console for errors + +### Content Not Saving + +* Verify backend handler +* Check network requests +* Review error logs + +### Preview Not Updating + +* Check markdown parser +* Verify event bindings +* Debug render function + +### Performance Issues + +* Profile rendering +* Optimize DOM updates +* Implement virtual scrolling + +## Next Steps + +* Review the Theme Development Guide +* Explore existing note type implementations +* Join the community to share your custom types \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/Plugin Development/Custom Widget Development Guid.md b/docs/Developer Guide/Developer Guide/Plugin Development/Custom Widget Development Guid.md new file mode 100644 index 0000000000..9405f6a841 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/Plugin Development/Custom Widget Development Guid.md @@ -0,0 +1,694 @@ +# Custom Widget Development Guide +Widgets are the building blocks of Trilium's user interface. This guide shows you how to create your own widgets to extend Trilium with custom functionality. + +## Getting Started + +To develop widgets, you'll need basic JavaScript knowledge and familiarity with jQuery. Widgets in Trilium follow a simple hierarchy where each type adds specific capabilities - BasicWidget for general UI, NoteContextAwareWidget for note-responsive widgets, and RightPanelWidget for sidebar panels. + +## Creating Your First Widget + +### Basic Widget + +Start with a simple widget that displays static content: + +```javascript +class MyWidget extends BasicWidget { + doRender() { + this.$widget = $('
Hello from my widget!
'); + } +} +``` + +### Note-Aware Widget + +To make your widget respond to note changes, extend NoteContextAwareWidget: + +```javascript +class NoteInfoWidget extends NoteContextAwareWidget { + doRender() { + this.$widget = $('
'); + } + + async refreshWithNote(note) { + this.$widget.html(` +

${note.title}

+

Type: ${note.type}

+ `); + } +} +``` + +The `refreshWithNote` method is automatically called whenever the user switches to a different note. + +### Right Panel Widget + +For widgets in the sidebar, extend RightPanelWidget: + +```javascript +class StatsWidget extends RightPanelWidget { + get widgetTitle() { return "Statistics"; } + + async doRenderBody() { + this.$body.html('
Loading...
'); + } + + async refreshWithNote(note) { + const content = await note.getContent(); + const words = content.split(/\s+/).length; + this.$body.find('.stats').text(`Words: ${words}`); + } +} +``` + +## Widget Lifecycle + +Widgets go through three main phases: + +**Initialization**: The `doRender()` method creates your widget's HTML structure. This happens once when the widget is first displayed. + +**Updates**: The `refresh()` or `refreshWithNote()` methods update your widget's content. These are called when data changes or the user switches notes. + +**Cleanup**: If your widget creates timers or external connections, override `cleanup()` to properly dispose of them. + +## Handling Events + +Widgets automatically subscribe to events based on method names. Simply define a method ending with "Event" to handle that event: + +```javascript +class ReactiveWidget extends NoteContextAwareWidget { + // Triggered when note content changes + async noteContentChangedEvent({ noteId }) { + if (this.noteId === noteId) { + await this.refresh(); + } + } + + // Triggered when user switches notes + async noteSwitchedEvent() { + console.log('Switched to:', this.noteId); + } +} +``` + +Common events include `noteSwitched`, `noteContentChanged`, and `entitiesReloaded`. The event system ensures your widget stays synchronized with Trilium's state. + +## State Management + +### Local State + +Store widget-specific state in instance properties: + +```typescript +class StatefulWidget extends BasicWidget { + constructor() { + super(); + this.isExpanded = false; + this.cachedData = null; + } + + toggleExpanded() { + this.isExpanded = !this.isExpanded; + this.$widget.toggleClass('expanded', this.isExpanded); + } +} +``` + +### Persistent State + +Use options or attributes for persistent state: + +```typescript +class PersistentWidget extends NoteContextAwareWidget { + async saveState(state) { + await server.put('options', { + name: 'widgetState', + value: JSON.stringify(state) + }); + } + + async loadState() { + const option = await server.get('options/widgetState'); + return option ? JSON.parse(option.value) : {}; + } +} +``` + +## Accessing Trilium APIs + +### Frontend Services + +```typescript +import froca from "../services/froca.js"; +import server from "../services/server.js"; +import linkService from "../services/link.js"; +import toastService from "../services/toast.js"; +import dialogService from "../services/dialog.js"; + +class ApiWidget extends NoteContextAwareWidget { + async doRenderBody() { + // Access notes + const note = await froca.getNote(this.noteId); + + // Get attributes + const attributes = note.getAttributes(); + + // Create links + const $link = await linkService.createLink(note.noteId); + + // Show notifications + toastService.showMessage("Widget loaded"); + + // Open dialogs + const result = await dialogService.confirm("Continue?"); + } +} +``` + +### Server Communication + +```typescript +class ServerWidget extends BasicWidget { + async loadData() { + // GET request + const data = await server.get('custom-api/data'); + + // POST request + const result = await server.post('custom-api/process', { + noteId: this.noteId, + action: 'analyze' + }); + + // PUT request + await server.put(`notes/${this.noteId}`, { + title: 'Updated Title' + }); + } +} +``` + +## Styling Widgets + +### Inline Styles + +```typescript +class StyledWidget extends BasicWidget { + doRender() { + this.$widget = $('
'); + this.css('padding', '10px') + .css('background-color', '#f0f0f0') + .css('border-radius', '4px'); + } +} +``` + +### CSS Classes + +```typescript +class ClassedWidget extends BasicWidget { + doRender() { + this.$widget = $('
'); + this.class('custom-widget') + .class('bordered'); + } +} +``` + +### CSS Blocks + +```typescript +class CSSBlockWidget extends BasicWidget { + doRender() { + this.$widget = $('
Content
'); + + this.cssBlock(` + .my-widget { + padding: 15px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 8px; + } + + .my-widget:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + } + `); + } +} +``` + +## Performance Optimization + +### Lazy Loading + +```typescript +class LazyWidget extends NoteContextAwareWidget { + constructor() { + super(); + this.dataLoaded = false; + } + + async refreshWithNote(note) { + if (!this.isVisible()) { + return; // Don't load if not visible + } + + if (!this.dataLoaded) { + await this.loadExpensiveData(); + this.dataLoaded = true; + } + + this.updateDisplay(); + } +} +``` + +### Debouncing Updates + +```typescript +import SpacedUpdate from "../services/spaced_update.js"; + +class DebouncedWidget extends NoteContextAwareWidget { + constructor() { + super(); + this.spacedUpdate = new SpacedUpdate(async () => { + await this.performUpdate(); + }, 500); // 500ms delay + } + + async handleInput(value) { + await this.spacedUpdate.scheduleUpdate(); + } +} +``` + +### Caching + +```typescript +class CachedWidget extends NoteContextAwareWidget { + constructor() { + super(); + this.cache = new Map(); + } + + async getProcessedData(noteId) { + if (!this.cache.has(noteId)) { + const data = await this.processExpensiveOperation(noteId); + this.cache.set(noteId, data); + } + return this.cache.get(noteId); + } + + cleanup() { + this.cache.clear(); + } +} +``` + +## Debugging Widgets + +### Console Logging + +```typescript +class DebugWidget extends BasicWidget { + doRender() { + console.log('Widget rendering', this.componentId); + console.time('render'); + + this.$widget = $('
'); + + console.timeEnd('render'); + } +} +``` + +### Error Handling + +```typescript +class SafeWidget extends NoteContextAwareWidget { + async refreshWithNote(note) { + try { + await this.riskyOperation(); + } catch (error) { + console.error('Widget error:', error); + this.logRenderingError(error); + this.$widget.html('
Failed to load
'); + } + } +} +``` + +### Development Tools + +```typescript +class DevWidget extends BasicWidget { + doRender() { + this.$widget = $('
'); + + // Add debug information in development + if (window.glob.isDev) { + this.$widget.attr('data-debug', 'true'); + this.$widget.append(` +
+ Component ID: ${this.componentId} + Position: ${this.position} +
+ `); + } + } +} +``` + +## Complete Example: Note Statistics Widget + +Here's a complete example implementing a custom note statistics widget: + +```typescript +import RightPanelWidget from "../widgets/right_panel_widget.js"; +import server from "../services/server.js"; +import froca from "../services/froca.js"; +import toastService from "../services/toast.js"; +import SpacedUpdate from "../services/spaced_update.js"; + +class NoteStatisticsWidget extends RightPanelWidget { + constructor() { + super(); + + // Initialize state + this.statistics = { + words: 0, + characters: 0, + paragraphs: 0, + readingTime: 0, + links: 0, + images: 0 + }; + + // Debounce updates for performance + this.spacedUpdate = new SpacedUpdate(async () => { + await this.calculateStatistics(); + }, 300); + } + + get widgetTitle() { + return "Note Statistics"; + } + + get help() { + return { + title: "Note Statistics", + text: "Displays various statistics about the current note including word count, reading time, and more." + }; + } + + async doRenderBody() { + this.$body.html(` +
+
+
Content
+
+ Words: + 0 +
+
+ Characters: + 0 +
+
+ Paragraphs: + 0 +
+
+ +
+
Reading
+
+ Reading time: + 0 min +
+
+ +
+
Elements
+
+ Links: + 0 +
+
+ Images: + 0 +
+
+ +
+ + +
+
+ `); + + this.cssBlock(` + .note-statistics { + padding: 10px; + } + + .stat-group { + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid var(--main-border-color); + } + + .stat-group:last-child { + border-bottom: none; + } + + .stat-group h5 { + margin: 0 0 10px 0; + color: var(--muted-text-color); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .stat-item { + display: flex; + justify-content: space-between; + padding: 5px 0; + } + + .stat-label { + color: var(--main-text-color); + } + + .stat-value { + font-weight: 600; + color: var(--primary-color); + } + + .stat-actions { + margin-top: 15px; + display: flex; + gap: 10px; + } + + .stat-actions .btn { + flex: 1; + } + `); + + // Bind events + this.$body.on('click', '.refresh-stats', () => this.handleRefresh()); + this.$body.on('click', '.export-stats', () => this.handleExport()); + } + + async refreshWithNote(note) { + if (!note) { + this.clearStatistics(); + return; + } + + // Schedule statistics calculation + await this.spacedUpdate.scheduleUpdate(); + } + + async calculateStatistics() { + try { + const note = this.note; + if (!note) return; + + const content = await note.getContent(); + + if (note.type === 'text') { + // Parse HTML content + const $content = $('
').html(content); + const textContent = $content.text(); + + // Calculate statistics + this.statistics.words = this.countWords(textContent); + this.statistics.characters = textContent.length; + this.statistics.paragraphs = $content.find('p').length; + this.statistics.readingTime = Math.ceil(this.statistics.words / 200); + this.statistics.links = $content.find('a').length; + this.statistics.images = $content.find('img').length; + } else if (note.type === 'code') { + // For code notes, count lines and characters + const lines = content.split('\n'); + this.statistics.words = lines.length; // Show lines instead of words + this.statistics.characters = content.length; + this.statistics.paragraphs = 0; + this.statistics.readingTime = 0; + this.statistics.links = 0; + this.statistics.images = 0; + } + + this.updateDisplay(); + + } catch (error) { + console.error('Failed to calculate statistics:', error); + toastService.showError("Failed to calculate statistics"); + } + } + + countWords(text) { + const words = text.match(/\b\w+\b/g); + return words ? words.length : 0; + } + + clearStatistics() { + this.statistics = { + words: 0, + characters: 0, + paragraphs: 0, + readingTime: 0, + links: 0, + images: 0 + }; + this.updateDisplay(); + } + + updateDisplay() { + this.$body.find('[data-stat="words"]').text(this.statistics.words); + this.$body.find('[data-stat="characters"]').text(this.statistics.characters); + this.$body.find('[data-stat="paragraphs"]').text(this.statistics.paragraphs); + this.$body.find('[data-stat="readingTime"]').text(`${this.statistics.readingTime} min`); + this.$body.find('[data-stat="links"]').text(this.statistics.links); + this.$body.find('[data-stat="images"]').text(this.statistics.images); + } + + async handleRefresh() { + await this.calculateStatistics(); + toastService.showMessage("Statistics refreshed"); + } + + async handleExport() { + const note = this.note; + if (!note) return; + + const exportData = { + noteId: note.noteId, + title: note.title, + statistics: this.statistics, + timestamp: new Date().toISOString() + }; + + // Create a CSV + const csv = [ + 'Metric,Value', + `Words,${this.statistics.words}`, + `Characters,${this.statistics.characters}`, + `Paragraphs,${this.statistics.paragraphs}`, + `Reading Time,${this.statistics.readingTime} minutes`, + `Links,${this.statistics.links}`, + `Images,${this.statistics.images}` + ].join('\n'); + + // Download CSV + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `statistics-${note.noteId}.csv`; + a.click(); + URL.revokeObjectURL(url); + + toastService.showMessage("Statistics exported"); + } + + async noteContentChangedEvent({ noteId }) { + if (this.noteId === noteId) { + await this.spacedUpdate.scheduleUpdate(); + } + } + + cleanup() { + this.$body.off('click'); + this.spacedUpdate = null; + } +} + +export default NoteStatisticsWidget; +``` + +## Best Practices + +### 1\. Memory Management + +* Clean up event listeners in `cleanup()` +* Clear caches and timers when widget is destroyed +* Avoid circular references + +### 2\. Performance + +* Use debouncing for frequent updates +* Implement lazy loading for expensive operations +* Cache computed values when appropriate + +### 3\. Error Handling + +* Always wrap async operations in try-catch +* Provide user feedback for errors +* Log errors for debugging + +### 4\. User Experience + +* Show loading states for async operations +* Provide clear error messages +* Ensure widgets are responsive + +### 5\. Code Organization + +* Keep widgets focused on a single responsibility +* Extract reusable logic into services +* Use composition over inheritance when possible + +## Troubleshooting + +### Widget Not Rendering + +* Check `doRender()` creates `this.$widget` +* Verify widget is properly registered +* Check console for errors + +### Events Not Firing + +* Ensure event method name matches pattern: `${eventName}Event` +* Check event is being triggered +* Verify widget is active/visible + +### State Not Persisting + +* Use options or attributes for persistence +* Check save operations complete successfully +* Verify data serialization + +### Performance Issues + +* Profile with browser dev tools +* Implement caching and debouncing +* Optimize DOM operations + +## Next Steps + +* Explore existing widgets in `/apps/client/src/widgets/` for examples +* Review the Frontend Script API documentation +* Join the Trilium community for support and sharing widgets \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/Plugin Development/Frontend Script Development.md b/docs/Developer Guide/Developer Guide/Plugin Development/Frontend Script Development.md new file mode 100644 index 0000000000..04b3155390 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/Plugin Development/Frontend Script Development.md @@ -0,0 +1,1042 @@ +# Frontend Script Development +## Frontend Script Development Guide + +This guide covers developing frontend scripts in Trilium Notes. Frontend scripts run in the browser context and can interact with the UI, modify behavior, and create custom functionality. + +## Prerequisites + +* JavaScript/TypeScript knowledge +* Understanding of browser APIs and DOM manipulation +* Basic knowledge of Trilium's note system +* Familiarity with async/await patterns + +## Getting Started + +### Creating a Frontend Script + +1. Create a new code note with type "JS Frontend" +2. Add the `#run=frontendStartup` label to run on startup +3. Write your JavaScript code + +```javascript +// Basic frontend script +api.addButtonToToolbar({ + title: 'My Custom Button', + icon: 'bx bx-star', + action: async () => { + await api.showMessage('Hello from custom script!'); + } +}); +``` + +### Script Execution Context + +Frontend scripts run in the browser with access to: + +* Trilium's Frontend API (`api` global object) +* Browser APIs (DOM, fetch, localStorage, etc.) +* jQuery (`$` global) +* All loaded libraries + +## Frontend API Reference + +### Core API Object + +The `api` object is globally available in all frontend scripts: + +```javascript +// Access current note +const currentNote = api.getActiveContextNote(); + +// Get note by ID +const note = await api.getNote('noteId123'); + +// Search notes +const results = await api.searchForNotes('type:text @label=important'); +``` + +### Note Operations + +#### Reading Notes + +```javascript +// Get active note +const activeNote = api.getActiveContextNote(); +console.log('Current note:', activeNote.title); + +// Get note by ID +const note = await api.getNote('noteId123'); + +// Get note content +const content = await note.getContent(); + +// Get note attributes +const attributes = note.getAttributes(); +const labels = note.getLabels(); +const relations = note.getRelations(); + +// Get child notes +const children = await note.getChildNotes(); + +// Get parent notes +const parents = await note.getParentNotes(); +``` + +#### Creating Notes + +```javascript +// Create a simple note +const newNote = await api.createNote( + parentNoteId, + 'New Note Title', + 'Note content here' +); + +// Create note with options +const note = await api.createNote( + parentNoteId, + 'Advanced Note', + '

HTML content

', + { + type: 'text', + mime: 'text/html', + isProtected: false + } +); + +// Create data note for storing JSON +const dataNote = await api.createDataNote( + parentNoteId, + 'config', + { key: 'value', settings: {} } +); +``` + +#### Modifying Notes + +```javascript +// Update note title +await note.setTitle('New Title'); + +// Update note content +await note.setContent('New content'); + +// Add label +await note.addLabel('status', 'completed'); + +// Add relation +await note.addRelation('relatedTo', targetNoteId); + +// Remove attribute +await note.removeAttribute(attributeId); + +// Toggle label +await note.toggleLabel('archived'); +await note.toggleLabel('priority', 'high'); +``` + +### UI Interaction + +#### Showing Messages + +```javascript +// Simple message +await api.showMessage('Operation completed'); + +// Error message +await api.showError('Something went wrong'); + +// Message with duration +await api.showMessage('Saved!', 3000); + +// Persistent message +const toast = await api.showPersistent({ + title: 'Processing', + message: 'Please wait...', + icon: 'loader' +}); + +// Close persistent message +toast.close(); +``` + +#### Dialogs + +```javascript +// Confirmation dialog +const confirmed = await api.showConfirmDialog({ + title: 'Delete Note?', + message: 'This action cannot be undone.', + okButtonLabel: 'Delete', + cancelButtonLabel: 'Keep' +}); + +if (confirmed) { + // Proceed with deletion +} + +// Prompt dialog +const input = await api.showPromptDialog({ + title: 'Enter Name', + message: 'Please enter a name for the new note:', + defaultValue: 'Untitled' +}); + +if (input) { + await api.createNote(parentId, input, ''); +} +``` + +### Custom Commands + +#### Adding Menu Items + +```javascript +// Add to note context menu +api.addContextMenuItemToNotes({ + title: 'Copy Note ID', + icon: 'bx bx-copy', + handler: async (note) => { + await navigator.clipboard.writeText(note.noteId); + await api.showMessage('Note ID copied'); + } +}); + +// Add to toolbar +api.addButtonToToolbar({ + title: 'Quick Action', + icon: 'bx bx-bolt', + shortcut: 'ctrl+shift+q', + action: async () => { + // Your action here + } +}); +``` + +#### Registering Commands + +```javascript +// Register a global command +api.bindGlobalShortcut('ctrl+shift+t', async () => { + const note = api.getActiveContextNote(); + const timestamp = new Date().toISOString(); + await note.addLabel('lastAccessed', timestamp); + await api.showMessage('Timestamp added'); +}); + +// Add command palette action +api.addCommandPaletteItem({ + name: 'Toggle Dark Mode', + description: 'Switch between light and dark themes', + action: async () => { + const currentTheme = await api.getOption('theme'); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + await api.setOption('theme', newTheme); + } +}); +``` + +### Event Handling + +#### Listening to Events + +```javascript +// Note switch event +api.onNoteChange(async ({ note, previousNote }) => { + console.log(`Switched from ${previousNote?.title} to ${note.title}`); + + // Update custom UI + updateCustomPanel(note); +}); + +// Content change event +api.onNoteContentChange(async ({ note }) => { + console.log(`Content changed for ${note.title}`); + + // Auto-save to external service + await syncToExternalService(note); +}); + +// Attribute change event +api.onAttributeChange(async ({ note, attribute }) => { + if (attribute.name === 'status' && attribute.value === 'completed') { + await note.addLabel('completedDate', new Date().toISOString()); + } +}); +``` + +#### Custom Events + +```javascript +// Trigger custom event +api.triggerEvent('myCustomEvent', { data: 'value' }); + +// Listen to custom event +api.onCustomEvent('myCustomEvent', async (data) => { + console.log('Custom event received:', data); +}); +``` + +### Working with Widgets + +```javascript +// Access widget system +const widget = api.getWidget('NoteTreeWidget'); + +// Refresh widget +await widget.refresh(); + +// Create custom widget container +const container = api.createCustomWidget({ + title: 'My Widget', + position: 'left', + render: async () => { + return ` +
+

Custom Content

+ +
+ `; + } +}); +``` + +## Complete Example: Auto-Formatting Script + +Here's a comprehensive example that automatically formats notes based on their type: + +```javascript +/** + * Auto-Formatting Script + * Automatically formats notes based on their type and content + */ + +class NoteFormatter { + constructor() { + this.setupEventListeners(); + this.registerCommands(); + } + + setupEventListeners() { + // Format on note save + api.onNoteContentChange(async ({ note }) => { + if (await this.shouldAutoFormat(note)) { + await this.formatNote(note); + } + }); + + // Format when label added + api.onAttributeChange(async ({ note, attribute }) => { + if (attribute.type === 'label' && + attribute.name === 'autoFormat' && + attribute.value === 'true') { + await this.formatNote(note); + } + }); + } + + registerCommands() { + // Add toolbar button + api.addButtonToToolbar({ + title: 'Format Note', + icon: 'bx bx-text', + shortcut: 'ctrl+shift+f', + action: async () => { + const note = api.getActiveContextNote(); + await this.formatNote(note); + await api.showMessage('Note formatted'); + } + }); + + // Add context menu item + api.addContextMenuItemToNotes({ + title: 'Auto-Format', + icon: 'bx bx-magic', + handler: async (note) => { + await this.formatNote(note); + } + }); + } + + async shouldAutoFormat(note) { + // Check if note has autoFormat label + const labels = note.getLabels(); + return labels.some(l => l.name === 'autoFormat' && l.value === 'true'); + } + + async formatNote(note) { + const type = note.type; + + switch (type) { + case 'text': + await this.formatTextNote(note); + break; + case 'code': + await this.formatCodeNote(note); + break; + case 'book': + await this.formatBookNote(note); + break; + } + } + + async formatTextNote(note) { + let content = await note.getContent(); + + // Apply formatting rules + content = this.addTableOfContents(content); + content = this.formatHeadings(content); + content = this.formatLists(content); + content = this.addMetadata(content, note); + + await note.setContent(content); + } + + async formatCodeNote(note) { + const content = await note.getContent(); + const language = note.getLabelValue('language') || 'javascript'; + + // Add syntax highlighting hints + if (!note.hasLabel('language')) { + await note.addLabel('language', language); + } + + // Format based on language + if (language === 'javascript' || language === 'typescript') { + await this.formatJavaScript(note, content); + } else if (language === 'python') { + await this.formatPython(note, content); + } + } + + async formatBookNote(note) { + // Organize child notes + const children = await note.getChildNotes(); + + // Sort chapters + const chapters = children.filter(n => n.hasLabel('chapter')); + chapters.sort((a, b) => { + const aNum = parseInt(a.getLabelValue('chapter')) || 999; + const bNum = parseInt(b.getLabelValue('chapter')) || 999; + return aNum - bNum; + }); + + // Generate table of contents + const toc = this.generateBookTOC(chapters); + await note.setContent(toc); + } + + addTableOfContents(content) { + const $content = $('
').html(content); + const headings = $content.find('h1, h2, h3'); + + if (headings.length < 3) return content; + + let toc = '
\n

Table of Contents

\n
    \n'; + + headings.each((i, heading) => { + const $h = $(heading); + const level = parseInt(heading.tagName.substring(1)); + const text = $h.text(); + const id = `heading-${i}`; + + $h.attr('id', id); + + const indent = ' '.repeat(level - 1); + toc += `${indent}
  • ${text}
  • \n`; + }); + + toc += '
\n
\n\n'; + + return toc + $content.html(); + } + + formatHeadings(content) { + const $content = $('
').html(content); + + // Ensure proper heading hierarchy + let lastLevel = 0; + $content.find('h1, h2, h3, h4, h5, h6').each((i, heading) => { + const $h = $(heading); + const level = parseInt(heading.tagName.substring(1)); + + // Fix heading jumps (e.g., h1 -> h3 becomes h1 -> h2) + if (level > lastLevel + 1) { + const newTag = `h${lastLevel + 1}`; + const $newHeading = $(`<${newTag}>`).html($h.html()); + $h.replaceWith($newHeading); + } + + lastLevel = level; + }); + + return $content.html(); + } + + formatLists(content) { + const $content = $('
').html(content); + + // Add classes to lists for styling + $content.find('ul').addClass('formatted-list'); + $content.find('ol').addClass('formatted-list numbered'); + + // Add checkboxes to task lists + $content.find('li').each((i, li) => { + const $li = $(li); + const text = $li.text(); + + if (text.startsWith('[ ] ')) { + $li.html(` ${text.substring(4)}`); + $li.addClass('task-item'); + } else if (text.startsWith('[x] ')) { + $li.html(` ${text.substring(4)}`); + $li.addClass('task-item completed'); + } + }); + + return $content.html(); + } + + addMetadata(content, note) { + const metadata = { + lastFormatted: new Date().toISOString(), + wordCount: content.replace(/<[^>]*>/g, '').split(/\s+/).length, + noteId: note.noteId + }; + + const metadataHtml = ` + + `; + + return content + metadataHtml; + } + + async formatJavaScript(note, content) { + // Add JSDoc comments if missing + const lines = content.split('\n'); + const formatted = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect function declarations + if (line.match(/^\s*(async\s+)?function\s+\w+/)) { + if (i === 0 || !lines[i-1].includes('*/')) { + formatted.push('/**'); + formatted.push(' * [Description]'); + formatted.push(' */'); + } + } + + formatted.push(line); + } + + await note.setContent(formatted.join('\n')); + } + + async formatPython(note, content) { + // Add docstrings if missing + const lines = content.split('\n'); + const formatted = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect function definitions + if (line.match(/^\s*def\s+\w+/)) { + formatted.push(line); + if (i + 1 < lines.length && !lines[i + 1].includes('"""')) { + formatted.push(' """[Description]"""'); + } + } else { + formatted.push(line); + } + } + + await note.setContent(formatted.join('\n')); + } + + generateBookTOC(chapters) { + let toc = '

Table of Contents

\n
    \n'; + + for (const chapter of chapters) { + const num = chapter.getLabelValue('chapter'); + const title = chapter.title; + toc += `
  1. ${num}. ${title}
  2. \n`; + } + + toc += '
'; + return toc; + } +} + +// Initialize formatter +const formatter = new NoteFormatter(); + +// Add settings UI +api.addSettingsTab({ + tabId: 'autoFormat', + title: 'Auto-Format', + render: () => { + return ` +
+

Auto-Format Settings

+ + + + + + + +

Format Rules

+ + + +
+ `; + } +}); + +// Save settings function +window.saveFormatSettings = async () => { + const settings = { + enableAutoFormat: document.getElementById('enableAutoFormat').checked, + formatOnSave: document.getElementById('formatOnSave').checked, + addTOC: document.getElementById('addTOC').checked, + rules: JSON.parse(document.getElementById('formatRules').value) + }; + + await api.setOption('autoFormatSettings', JSON.stringify(settings)); + await api.showMessage('Settings saved'); +}; + +console.log('Auto-formatting script loaded'); +``` + +## Advanced Techniques + +### Working with External APIs + +```javascript +// Fetch data from external API +async function fetchExternalData() { + try { + const response = await fetch('https://api.example.com/data', { + headers: { + 'Authorization': `Bearer ${await api.getOption('apiKey')}` + } + }); + + const data = await response.json(); + + // Store in note + const dataNote = await api.createDataNote( + 'root', + 'External Data', + data + ); + + await api.showMessage('Data imported successfully'); + + } catch (error) { + await api.showError(`Failed to fetch data: ${error.message}`); + } +} +``` + +### State Management + +```javascript +// Create a state manager +class StateManager { + constructor() { + this.state = {}; + this.subscribers = []; + this.loadState(); + } + + async loadState() { + const stored = await api.getOption('scriptState'); + if (stored) { + this.state = JSON.parse(stored); + } + } + + async setState(key, value) { + this.state[key] = value; + await this.saveState(); + this.notifySubscribers(key, value); + } + + getState(key) { + return this.state[key]; + } + + async saveState() { + await api.setOption('scriptState', JSON.stringify(this.state)); + } + + subscribe(callback) { + this.subscribers.push(callback); + } + + notifySubscribers(key, value) { + this.subscribers.forEach(cb => cb(key, value)); + } +} + +const state = new StateManager(); +``` + +### Custom UI Components + +```javascript +// Create custom panel +class CustomPanel { + constructor() { + this.createPanel(); + } + + createPanel() { + const $panel = $(` +
+
+

Custom Panel

+ +
+
+ +
+
+ `); + + $('body').append($panel); + + // Add styles + $('