mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	Compare commits
	
		
			142 Commits
		
	
	
		
			react/type
			...
			tree-activ
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 914483151a | ||
|  | 5db4c39051 | ||
|  | 0ad95d00dc | ||
|  | 5b7e9d4c12 | ||
|  | bee2fdb22f | ||
|  | 5c46a0dfa8 | ||
|  | 69b262040a | ||
|  | 8731fa6c31 | ||
|  | f4e8fc4d83 | ||
|  | dd5b3a3c1c | ||
|  | 17319d25e8 | ||
|  | 2f189b6961 | ||
|  | b1f8d44576 | ||
|  | 7f22532a0a | ||
|  | c7beb87980 | ||
|  | 5cd1fd53d4 | ||
|  | 2eadbe3f01 | ||
|  | 4e7493f648 | ||
|  | b9d54a44f6 | ||
|  | a1ad8be02b | ||
|  | b02514f395 | ||
|  | dcef3f2be5 | ||
|  | 585fdabd27 | ||
|  | 71fcb77a22 | ||
|  | 33ecf6aa6d | ||
|  | 1f75de83c6 | ||
|  | 31b52f72d2 | ||
|  | 01aaf81196 | ||
|  | 3ecfdd62e8 | ||
|  | 3c74d0714a | ||
|  | f58d9adff2 | ||
|  | 0eecf5b132 | ||
|  | 9e3cca333a | ||
|  | 81c233463e | ||
|  | 87946e7e85 | ||
|  | c3768a051d | ||
|  | c579cd3ce7 | ||
|  | 945e2625d3 | ||
|  | ff36414a55 | ||
|  | 8f184c5b10 | ||
|  | c027a2bbfa | ||
|  | 91adc2258d | ||
|  | 6701e83927 | ||
|  | 3f54e589d8 | ||
|  | f65be73f71 | ||
|  | 346e9282bd | ||
|  | 8f8ea7adc3 | ||
|  | 4affd3a955 | ||
|  | bcce05cc4d | ||
|  | ac16c42e23 | ||
|  | 5025329e92 | ||
|  | 507910b0ce | ||
|  | b59fab9dba | ||
|  | ac7e4580f6 | ||
|  | 27d1044ba8 | ||
|  | 96c949b2fc | ||
|  | 927cd0255e | ||
|  | c2c8417c42 | ||
|  | 3bb224e682 | ||
|  | 6f126ea17b | ||
|  | 61a5cf1452 | ||
|  | 14b8d0a47e | ||
|  | 12df6a0d6e | ||
|  | 21d243eec1 | ||
|  | 161238ca11 | ||
|  | 4d5267e18b | ||
|  | 0fa52907b3 | ||
|  | c4f57f3d15 | ||
|  | 6bde264156 | ||
|  | 4f72f81a95 | ||
|  | c212c5d6ff | ||
|  | f24880d42c | ||
|  | ee9bf1d47b | ||
|  | b069fab82f | ||
|  | d5ce01a65b | ||
|  | dbfa94a9ee | ||
|  | 86aaa97809 | ||
|  | c4c8fe23a9 | ||
|  | 715fe77db3 | ||
|  | 40f5abd6e3 | ||
|  | f3f7e5900b | ||
|  | f4402a6d81 | ||
|  | 6966efd374 | ||
|  | cd3e025fdc | ||
|  | a224b774d3 | ||
|  | f20078f3b0 | ||
|  | 56019e5449 | ||
|  | 7dd517d8f7 | ||
|  | b2f1b3c910 | ||
|  | 2197fae700 | ||
|  | 3661733f07 | ||
|  | 52a6f2597e | ||
|  | ba26c478d6 | ||
|  | 055fcb7b2a | ||
|  | f4468706ef | ||
|  | 212956201a | ||
|  | 1182592fc5 | ||
|  | 1a68bdfe02 | ||
|  | 0c399a676a | ||
|  | 395f33cd5b | ||
|  | 21b20cf575 | ||
|  | e3dd25b591 | ||
|  | b9a4e7ab11 | ||
|  | 6ae67c410c | ||
|  | 4ef7667484 | ||
|  | 3660e2f127 | ||
|  | 357d294f2d | ||
|  | bb636128b0 | ||
|  | aa102ab393 | ||
|  | ea53665e64 | ||
|  | 9cf7fa1997 | ||
|  | fded714f18 | ||
|  | 06de06b501 | ||
|  | 9abdbbbc5b | ||
|  | 3ebfee8bd2 | ||
|  | 6d446c5b27 | ||
|  | 3a55490bbf | ||
|  | bc4643fed2 | ||
|  | a2110ca631 | ||
|  | 413137ac64 | ||
|  | 9bc966491d | ||
|  | 61dbc15fc6 | ||
|  | b475037127 | ||
|  | 35622a2122 | ||
|  | 77e4c3d0ec | ||
|  | 8523050ab2 | ||
|  | 0efdf65202 | ||
|  | acb0991d05 | ||
|  | a9f68f5487 | ||
|  | 55bb2fdb9b | ||
|  | e529633b8b | ||
|  | dfd575b6eb | ||
|  | c5196721d4 | ||
|  | 968c75b618 | ||
|  | 01beebf660 | ||
|  | d3115e834a | ||
|  | 01a552ceb5 | ||
|  | d8958adea5 | ||
|  | 4d5e866db6 | ||
|  | f189deb415 | ||
|  | 9c460dbc87 | ||
|  | 2c6ba9ba2c | 
							
								
								
									
										2
									
								
								.github/actions/build-server/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/build-server/action.yml
									
									
									
									
										vendored
									
									
								
							| @@ -12,7 +12,7 @@ runs: | ||||
|   - name: Set up node & dependencies | ||||
|     uses: actions/setup-node@v6 | ||||
|     with: | ||||
|       node-version: 22 | ||||
|       node-version: 24 | ||||
|       cache: "pnpm" | ||||
|   - name: Install dependencies | ||||
|     shell: bash | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
								
							| @@ -74,7 +74,7 @@ jobs: | ||||
|       - name: Setup Node.js | ||||
|         uses: actions/setup-node@v6 | ||||
|         with: | ||||
|           node-version: '22' | ||||
|           node-version: '24' | ||||
|           cache: 'pnpm' | ||||
|  | ||||
|       # Install Node.js dependencies for the TypeScript script | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/dev.yml
									
									
									
									
										vendored
									
									
								
							| @@ -30,7 +30,7 @@ jobs: | ||||
|       - name: Set up node & dependencies | ||||
|         uses: actions/setup-node@v6 | ||||
|         with: | ||||
|           node-version: 22 | ||||
|           node-version: 24 | ||||
|           cache: "pnpm" | ||||
|       - run: pnpm install --frozen-lockfile | ||||
|  | ||||
|   | ||||
							
								
								
									
										8
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/main-docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -46,7 +46,7 @@ jobs: | ||||
|       - name: Set up node & dependencies | ||||
|         uses: actions/setup-node@v6 | ||||
|         with: | ||||
|           node-version: 22 | ||||
|           node-version: 24 | ||||
|           cache: "pnpm" | ||||
|  | ||||
|       - name: Install npm dependencies | ||||
| @@ -116,10 +116,10 @@ jobs: | ||||
|           - dockerfile: Dockerfile | ||||
|             platform: linux/arm64 | ||||
|             image: ubuntu-24.04-arm | ||||
|           - dockerfile: Dockerfile | ||||
|           - dockerfile: Dockerfile.legacy | ||||
|             platform: linux/arm/v7 | ||||
|             image: ubuntu-24.04-arm | ||||
|           - dockerfile: Dockerfile | ||||
|           - dockerfile: Dockerfile.legacy | ||||
|             platform: linux/arm/v8 | ||||
|             image: ubuntu-24.04-arm | ||||
|     runs-on: ${{ matrix.image }} | ||||
| @@ -146,7 +146,7 @@ jobs: | ||||
|       - name: Set up node & dependencies | ||||
|         uses: actions/setup-node@v6 | ||||
|         with: | ||||
|           node-version: 22 | ||||
|           node-version: 24 | ||||
|           cache: 'pnpm' | ||||
|  | ||||
|       - name: Install dependencies | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							| @@ -52,7 +52,7 @@ jobs: | ||||
|       - name: Set up node & dependencies | ||||
|         uses: actions/setup-node@v6 | ||||
|         with: | ||||
|           node-version: 22 | ||||
|           node-version: 24 | ||||
|           cache: 'pnpm' | ||||
|       - name: Install dependencies | ||||
|         run: pnpm install --frozen-lockfile | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
								
							| @@ -24,7 +24,7 @@ jobs: | ||||
|       - uses: pnpm/action-setup@v4 | ||||
|       - uses: actions/setup-node@v6 | ||||
|         with: | ||||
|           node-version: 22 | ||||
|           node-version: 24 | ||||
|           cache: 'pnpm' | ||||
|  | ||||
|       - name: Install dependencies | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -50,7 +50,7 @@ jobs: | ||||
|       - name: Set up node & dependencies | ||||
|         uses: actions/setup-node@v6 | ||||
|         with: | ||||
|           node-version: 22 | ||||
|           node-version: 24 | ||||
|           cache: 'pnpm' | ||||
|       - name: Install dependencies | ||||
|         run: pnpm install --frozen-lockfile | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/website.yml
									
									
									
									
										vendored
									
									
								
							| @@ -30,7 +30,7 @@ jobs: | ||||
|       - name: Set up node & dependencies | ||||
|         uses: actions/setup-node@v6 | ||||
|         with: | ||||
|           node-version: 22 | ||||
|           node-version: 24 | ||||
|           cache: "pnpm" | ||||
|  | ||||
|       - name: Install dependencies | ||||
|   | ||||
| @@ -37,8 +37,8 @@ | ||||
|   "devDependencies": {     | ||||
|     "@playwright/test": "1.56.1", | ||||
|     "@stylistic/eslint-plugin": "5.5.0",         | ||||
|     "@types/express": "5.0.4",     | ||||
|     "@types/node": "22.18.12",     | ||||
|     "@types/express": "5.0.5",     | ||||
|     "@types/node": "24.9.1",     | ||||
|     "@types/yargs": "17.0.34", | ||||
|     "@vitest/coverage-v8": "3.2.4", | ||||
|     "eslint": "9.38.0", | ||||
|   | ||||
| @@ -54,12 +54,12 @@ | ||||
|     "leaflet-gpx": "2.2.0", | ||||
|     "mark.js": "8.11.1", | ||||
|     "marked": "16.4.1", | ||||
|     "mermaid": "11.12.0", | ||||
|     "mermaid": "11.12.1", | ||||
|     "mind-elixir": "5.3.4", | ||||
|     "normalize.css": "8.0.1", | ||||
|     "panzoom": "9.4.3", | ||||
|     "preact": "10.27.2", | ||||
|     "react-i18next": "16.2.0", | ||||
|     "react-i18next": "16.2.1", | ||||
|     "reveal.js": "5.2.1", | ||||
|     "svg-pan-zoom": "3.6.2", | ||||
|     "tabulator-tables": "6.3.1", | ||||
|   | ||||
| @@ -218,12 +218,12 @@ export type CommandMappings = { | ||||
|     /** Works only in the electron context menu. */ | ||||
|     replaceMisspelling: CommandData; | ||||
|  | ||||
|     importMarkdownInline: CommandData; | ||||
|     showPasswordNotSet: CommandData; | ||||
|     showProtectedSessionPasswordDialog: CommandData; | ||||
|     showUploadAttachmentsDialog: CommandData & { noteId: string }; | ||||
|     showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget }; | ||||
|     showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string }; | ||||
|     showPasteMarkdownDialog: CommandData & { textTypeWidget: EditableTextTypeWidget }; | ||||
|     closeProtectedSessionPasswordDialog: CommandData; | ||||
|     copyImageReferenceToClipboard: CommandData; | ||||
|     copyImageToClipboard: CommandData; | ||||
|   | ||||
| @@ -417,7 +417,7 @@ export default class FNote { | ||||
|         return notePaths; | ||||
|     } | ||||
|  | ||||
|     getSortedNotePathRecords(hoistedNoteId = "root"): NotePathRecord[] { | ||||
|     getSortedNotePathRecords(hoistedNoteId = "root", activeNotePath: string | null = null): NotePathRecord[] { | ||||
|         const isHoistedRoot = hoistedNoteId === "root"; | ||||
|  | ||||
|         const notePaths: NotePathRecord[] = this.getAllNotePaths().map((path) => ({ | ||||
| @@ -428,7 +428,23 @@ export default class FNote { | ||||
|             isHidden: path.includes("_hidden") | ||||
|         })); | ||||
|  | ||||
|         // Calculate the length of the prefix match between two arrays | ||||
|         const prefixMatchLength = (path: string[], target: string[]) => { | ||||
|             const diffIndex = path.findIndex((seg, i) => seg !== target[i]); | ||||
|             return diffIndex === -1 ? Math.min(path.length, target.length) : diffIndex; | ||||
|         }; | ||||
|  | ||||
|         notePaths.sort((a, b) => { | ||||
|             if (activeNotePath) { | ||||
|                 const activeSegments = activeNotePath.split('/'); | ||||
|                 const aOverlap = prefixMatchLength(a.notePath, activeSegments); | ||||
|                 const bOverlap = prefixMatchLength(b.notePath, activeSegments); | ||||
|                 // Paths with more matching prefix segments are prioritized | ||||
|                 // when the match count is equal, other criteria are used for sorting | ||||
|                 if (bOverlap !== aOverlap) { | ||||
|                     return bOverlap - aOverlap; | ||||
|                 } | ||||
|             } | ||||
|             if (a.isInHoistedSubTree !== b.isInHoistedSubTree) { | ||||
|                 return a.isInHoistedSubTree ? -1 : 1; | ||||
|             } else if (a.isArchived !== b.isArchived) { | ||||
| @@ -449,10 +465,11 @@ export default class FNote { | ||||
|      * Returns the note path considered to be the "best" | ||||
|      * | ||||
|      * @param {string} [hoistedNoteId='root'] | ||||
|      * @param {string|null} [activeNotePath=null] | ||||
|      * @return {string[]} array of noteIds constituting the particular note path | ||||
|      */ | ||||
|     getBestNotePath(hoistedNoteId = "root") { | ||||
|         return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath; | ||||
|     getBestNotePath(hoistedNoteId = "root", activeNotePath: string | null = null) { | ||||
|         return this.getSortedNotePathRecords(hoistedNoteId, activeNotePath)[0]?.notePath; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -56,7 +56,20 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) { | ||||
|                 await import("@triliumnext/ckeditor5/src/theme/ck-content.css"); | ||||
|             } | ||||
|             const { $renderedContent } = await content_renderer.getRenderedContent(note, { noChildrenList: true }); | ||||
|             containerRef.current?.replaceChildren(...$renderedContent); | ||||
|             const container = containerRef.current!; | ||||
|             container.replaceChildren(...$renderedContent); | ||||
|  | ||||
|             // Wait for all images to load. | ||||
|             const images = Array.from(container.querySelectorAll("img")); | ||||
|             await Promise.all( | ||||
|                 images.map(img => { | ||||
|                     if (img.complete) return Promise.resolve(); | ||||
|                     return new Promise<void>(resolve => { | ||||
|                         img.addEventListener("load", () => resolve(), { once: true }); | ||||
|                         img.addEventListener("error", () => resolve(), { once: true }); | ||||
|                     }); | ||||
|                 }) | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         load().then(() => requestAnimationFrame(onReady)) | ||||
|   | ||||
| @@ -20,9 +20,6 @@ function setupGlobs() { | ||||
|     window.glob.froca = froca; | ||||
|     window.glob.treeCache = froca; // compatibility for CKEditor builds for a while | ||||
|  | ||||
|     // for CKEditor integration (button on block toolbar) | ||||
|     window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline"); | ||||
|  | ||||
|     window.onerror = function (msg, url, lineNo, columnNo, error) { | ||||
|         const string = String(msg).toLowerCase(); | ||||
|  | ||||
|   | ||||
| @@ -26,21 +26,12 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root | ||||
|     } | ||||
|  | ||||
|     const path = notePath.split("/").reverse(); | ||||
|  | ||||
|     if (!path.includes("root")) { | ||||
|         path.push("root"); | ||||
|     } | ||||
|  | ||||
|     const effectivePathSegments: string[] = []; | ||||
|     let childNoteId: string | null = null; | ||||
|     let i = 0; | ||||
|  | ||||
|     while (true) { | ||||
|         if (i >= path.length) { | ||||
|             break; | ||||
|         } | ||||
|  | ||||
|         const parentNoteId = path[i++]; | ||||
|     for (let i = 0; i < path.length; i++) { | ||||
|         const parentNoteId = path[i]; | ||||
|  | ||||
|         if (childNoteId !== null) { | ||||
|             const child = await froca.getNote(childNoteId, !logErrors); | ||||
| @@ -65,7 +56,7 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             if (!parents.some((p) => p.noteId === parentNoteId)) { | ||||
|             if (!parents.some(p => p.noteId === parentNoteId) || (i === path.length - 1 && parentNoteId !== 'root')) { | ||||
|                 if (logErrors) { | ||||
|                     const parent = froca.getNoteFromCache(parentNoteId); | ||||
|  | ||||
| @@ -77,7 +68,8 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root | ||||
|                     ); | ||||
|                 } | ||||
|  | ||||
|                 const bestNotePath = child.getBestNotePath(hoistedNoteId); | ||||
|                 const activeNotePath = appContext.tabManager.getActiveContextNotePath(); | ||||
|                 const bestNotePath = child.getBestNotePath(hoistedNoteId, activeNotePath); | ||||
|  | ||||
|                 if (bestNotePath) { | ||||
|                     const pathToRoot = bestNotePath.reverse().slice(1); | ||||
| @@ -108,7 +100,9 @@ async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root | ||||
|         if (!note) { | ||||
|             throw new Error(`Unable to find note: ${notePath}.`); | ||||
|         } | ||||
|         const bestNotePath = note.getBestNotePath(hoistedNoteId); | ||||
|  | ||||
|         const activeNotePath = appContext.tabManager.getActiveContextNotePath(); | ||||
|         const bestNotePath = note.getBestNotePath(hoistedNoteId, activeNotePath); | ||||
|  | ||||
|         if (!bestNotePath) { | ||||
|             throw new Error(`Did not find any path segments for '${note.toString()}', hoisted note '${hoistedNoteId}'`); | ||||
|   | ||||
| @@ -9,16 +9,6 @@ async function ensureJQuery() { | ||||
|     (window as any).$ = $; | ||||
| } | ||||
|  | ||||
| async function applyMath() { | ||||
|     const anyMathBlock = document.querySelector("#content .math-tex"); | ||||
|     if (!anyMathBlock) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const renderMathInElement = (await import("./services/math.js")).renderMathInElement; | ||||
|     renderMathInElement(document.getElementById("content")); | ||||
| } | ||||
|  | ||||
| async function formatCodeBlocks() { | ||||
|     const anyCodeBlock = document.querySelector("#content pre"); | ||||
|     if (!anyCodeBlock) { | ||||
| @@ -31,54 +21,4 @@ async function formatCodeBlocks() { | ||||
|  | ||||
| async function setupTextNote() { | ||||
|     formatCodeBlocks(); | ||||
|     applyMath(); | ||||
|  | ||||
|     const setupMermaid = (await import("./share/mermaid.js")).default; | ||||
|     setupMermaid(); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Fetch note with given ID from backend | ||||
|  * | ||||
|  * @param noteId of the given note to be fetched. If false, fetches current note. | ||||
|  */ | ||||
| async function fetchNote(noteId: string | null = null) { | ||||
|     if (!noteId) { | ||||
|         noteId = document.body.getAttribute("data-note-id"); | ||||
|     } | ||||
|  | ||||
|     const resp = await fetch(`api/notes/${noteId}`); | ||||
|  | ||||
|     return await resp.json(); | ||||
| } | ||||
|  | ||||
| document.addEventListener( | ||||
|     "DOMContentLoaded", | ||||
|     () => { | ||||
|         const noteType = determineNoteType(); | ||||
|  | ||||
|         if (noteType === "text") { | ||||
|             setupTextNote(); | ||||
|         } | ||||
|  | ||||
|         const toggleMenuButton = document.getElementById("toggleMenuButton"); | ||||
|         const layout = document.getElementById("layout"); | ||||
|  | ||||
|         if (toggleMenuButton && layout) { | ||||
|             toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu")); | ||||
|         } | ||||
|     }, | ||||
|     false | ||||
| ); | ||||
|  | ||||
| function determineNoteType() { | ||||
|     const bodyClass = document.body.className; | ||||
|     const match = bodyClass.match(/type-([^\s]+)/); | ||||
|     return match ? match[1] : null; | ||||
| } | ||||
|  | ||||
| // workaround to prevent webpack from removing "fetchNote" as dead code: | ||||
| // add fetchNote as property to the window object | ||||
| Object.defineProperty(window, "fetchNote", { | ||||
|     value: fetchNote | ||||
| }); | ||||
|   | ||||
| @@ -2034,9 +2034,9 @@ body.zen #right-pane, | ||||
| body.zen #mobile-sidebar-wrapper, | ||||
| body.zen .tab-row-container, | ||||
| body.zen .tab-row-widget, | ||||
| body.zen .ribbon-container:not(:has(.classic-toolbar-widget.visible)), | ||||
| body.zen .ribbon-container:has(.classic-toolbar-widget.visible) .ribbon-top-row, | ||||
| body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget.visible)), | ||||
| body.zen .ribbon-container:not(:has(.classic-toolbar-widget)), | ||||
| body.zen .ribbon-container:has(.classic-toolbar-widget) .ribbon-top-row, | ||||
| body.zen .ribbon-container .ribbon-body:not(:has(.classic-toolbar-widget)), | ||||
| body.zen .note-icon-widget, | ||||
| body.zen .title-row .icon-action, | ||||
| body.zen .floating-buttons-children > *:not(.bx-edit-alt), | ||||
|   | ||||
| @@ -259,7 +259,6 @@ | ||||
|     "delete_all_revisions": "删除此笔记的所有修订版本", | ||||
|     "delete_all_button": "删除所有修订版本", | ||||
|     "help_title": "关于笔记修订版本的帮助", | ||||
|     "revision_last_edited": "此修订版本上次编辑于 {{date}}", | ||||
|     "confirm_delete_all": "您是否要删除此笔记的所有修订版本?", | ||||
|     "no_revisions": "此笔记暂无修订版本...", | ||||
|     "restore_button": "恢复", | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
|     "homepage": "Startseite:", | ||||
|     "app_version": "App-Version:", | ||||
|     "db_version": "DB-Version:", | ||||
|     "sync_version": "Synch-version:", | ||||
|     "sync_version": "Sync-Version:", | ||||
|     "build_date": "Build-Datum:", | ||||
|     "build_revision": "Build-Revision:", | ||||
|     "data_directory": "Datenverzeichnis:" | ||||
| @@ -260,7 +260,6 @@ | ||||
|     "delete_all_revisions": "Lösche alle Revisionen dieser Notiz", | ||||
|     "delete_all_button": "Alle Revisionen löschen", | ||||
|     "help_title": "Hilfe zu Notizrevisionen", | ||||
|     "revision_last_edited": "Diese Revision wurde zuletzt am {{date}} bearbeitet", | ||||
|     "confirm_delete_all": "Möchtest du alle Revisionen dieser Notiz löschen?", | ||||
|     "no_revisions": "Für diese Notiz gibt es noch keine Revisionen...", | ||||
|     "confirm_restore": "Möchtest du diese Revision wiederherstellen? Dadurch werden der aktuelle Titel und Inhalt der Notiz mit dieser Revision überschrieben.", | ||||
| @@ -991,7 +990,7 @@ | ||||
|     "enter_password_instruction": "Um die geschützte Notiz anzuzeigen, musst du dein Passwort eingeben:", | ||||
|     "start_session_button": "Starte eine geschützte Sitzung <kbd>Eingabetaste</kbd>", | ||||
|     "started": "Geschützte Sitzung gestartet.", | ||||
|     "wrong_password": "Passwort flasch.", | ||||
|     "wrong_password": "Passwort falsch.", | ||||
|     "protecting-finished-successfully": "Geschützt erfolgreich beendet.", | ||||
|     "unprotecting-finished-successfully": "Ungeschützt erfolgreich beendet.", | ||||
|     "protecting-in-progress": "Schützen läuft: {{count}}", | ||||
| @@ -1658,7 +1657,7 @@ | ||||
|     "add-term-to-dictionary": "Begriff \"{{term}}\" zum Wörterbuch hinzufügen", | ||||
|     "cut": "Ausschneiden", | ||||
|     "copy": "Kopieren", | ||||
|     "copy-link": "Link opieren", | ||||
|     "copy-link": "Link kopieren", | ||||
|     "paste": "Einfügen", | ||||
|     "paste-as-plain-text": "Als unformatierten Text einfügen", | ||||
|     "search_online": "Suche nach \"{{term}}\" mit {{searchEngine}} starten" | ||||
|   | ||||
| @@ -104,7 +104,8 @@ | ||||
|     "export_status": "Export status", | ||||
|     "export_in_progress": "Export in progress: {{progressCount}}", | ||||
|     "export_finished_successfully": "Export finished successfully.", | ||||
|     "format_pdf": "PDF - for printing or sharing purposes." | ||||
|     "format_pdf": "PDF - for printing or sharing purposes.", | ||||
|     "share-format": "HTML for web publishing - uses the same theme that is used shared notes, but can be published as a static website." | ||||
|   }, | ||||
|   "help": { | ||||
|     "title": "Cheatsheet", | ||||
| @@ -260,7 +261,6 @@ | ||||
|     "delete_all_revisions": "Delete all revisions of this note", | ||||
|     "delete_all_button": "Delete all revisions", | ||||
|     "help_title": "Help on Note Revisions", | ||||
|     "revision_last_edited": "This revision was last edited on {{date}}", | ||||
|     "confirm_delete_all": "Do you want to delete all revisions of this note?", | ||||
|     "no_revisions": "No revisions for this note yet...", | ||||
|     "restore_button": "Restore", | ||||
|   | ||||
| @@ -259,7 +259,6 @@ | ||||
|     "delete_all_revisions": "Eliminar todas las revisiones de esta nota", | ||||
|     "delete_all_button": "Eliminar todas las revisiones", | ||||
|     "help_title": "Ayuda sobre revisiones de notas", | ||||
|     "revision_last_edited": "Esta revisión se editó por última vez en {{date}}", | ||||
|     "confirm_delete_all": "¿Quiere eliminar todas las revisiones de esta nota?", | ||||
|     "no_revisions": "Aún no hay revisiones para esta nota...", | ||||
|     "restore_button": "Restaurar", | ||||
|   | ||||
| @@ -260,7 +260,6 @@ | ||||
|     "delete_all_revisions": "Supprimer toutes les versions de cette note", | ||||
|     "delete_all_button": "Supprimer toutes les versions", | ||||
|     "help_title": "Aide sur les versions de notes", | ||||
|     "revision_last_edited": "Cette version a été modifiée pour la dernière fois le {{date}}", | ||||
|     "confirm_delete_all": "Voulez-vous supprimer toutes les versions de cette note ?", | ||||
|     "no_revisions": "Aucune version pour cette note pour l'instant...", | ||||
|     "confirm_restore": "Voulez-vous restaurer cette version ? Le titre et le contenu actuels de la note seront écrasés par cette version.", | ||||
|   | ||||
							
								
								
									
										5
									
								
								apps/client/src/translations/hi/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								apps/client/src/translations/hi/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| { | ||||
|   "about": { | ||||
|     "title": "ट्रिलियम नोट्स के बारें में" | ||||
|   } | ||||
| } | ||||
| @@ -867,7 +867,6 @@ | ||||
|     "delete_all_revisions": "Elimina tutte le revisioni di questa nota", | ||||
|     "delete_all_button": "Elimina tutte le revisioni", | ||||
|     "help_title": "Aiuto sulle revisioni delle note", | ||||
|     "revision_last_edited": "Questa revisione è stata modificata l'ultima volta il {{date}}", | ||||
|     "confirm_delete_all": "Vuoi eliminare tutte le revisioni di questa nota?", | ||||
|     "no_revisions": "Ancora nessuna revisione per questa nota...", | ||||
|     "restore_button": "Ripristina", | ||||
|   | ||||
| @@ -610,7 +610,6 @@ | ||||
|     "delete_all_revisions": "このノートの変更履歴をすべて削除", | ||||
|     "delete_all_button": "変更履歴をすべて削除", | ||||
|     "help_title": "変更履歴のヘルプ", | ||||
|     "revision_last_edited": "この変更は{{date}}に行われました", | ||||
|     "confirm_delete_all": "このノートのすべての変更履歴を削除しますか?", | ||||
|     "no_revisions": "このノートに変更履歴はまだありません...", | ||||
|     "restore_button": "復元", | ||||
|   | ||||
| @@ -13,6 +13,13 @@ | ||||
|     "critical-error": { | ||||
|       "title": "Kritische Error", | ||||
|       "message": "Een kritieke fout heeft plaatsgevonden waardoor de cliënt zich aanmeldt vanaf het begin:\n\n84X\n\nDit is waarschijnlijk veroorzaakt door een script dat op een onverwachte manier faalt. Probeer de sollicitatie in veilige modus te starten en de kwestie aan te spreken." | ||||
|     }, | ||||
|     "widget-error": { | ||||
|       "title": "Starten widget mislukt", | ||||
|       "message-unknown": "Onbekende widget kan niet gestart worden omdat:\n\n{{message}}" | ||||
|     }, | ||||
|     "bundle-error": { | ||||
|       "title": "Custom script laden mislukt" | ||||
|     } | ||||
|   }, | ||||
|   "add_link": { | ||||
|   | ||||
| @@ -912,7 +912,6 @@ | ||||
|     "delete_all_revisions": "Usuń wszystkie wersje tej notatki", | ||||
|     "delete_all_button": "Usuń wszystkie wersje", | ||||
|     "help_title": "Pomoc dotycząca wersji notatki", | ||||
|     "revision_last_edited": "Ta wersja była ostatnio edytowana {{date}}", | ||||
|     "confirm_delete_all": "Czy chcesz usunąć wszystkie wersje tej notatki?", | ||||
|     "no_revisions": "Brak wersji dla tej notatki...", | ||||
|     "restore_button": "Przywróć", | ||||
|   | ||||
| @@ -259,7 +259,6 @@ | ||||
|     "delete_all_revisions": "Apagar todas as versões desta nota", | ||||
|     "delete_all_button": "Apagar todas as versões", | ||||
|     "help_title": "Ajuda sobre as versões da nota", | ||||
|     "revision_last_edited": "Esta versão foi editada pela última vez em {{date}}", | ||||
|     "confirm_delete_all": "Quer apagar todas as versões desta nota?", | ||||
|     "no_revisions": "Ainda não há versões para esta nota...", | ||||
|     "restore_button": "Recuperar", | ||||
|   | ||||
| @@ -415,7 +415,6 @@ | ||||
|     "delete_all_revisions": "Excluir todas as versões desta nota", | ||||
|     "delete_all_button": "Excluir todas as versões", | ||||
|     "help_title": "Ajuda sobre as versões da nota", | ||||
|     "revision_last_edited": "Esta versão foi editada pela última vez em {{date}}", | ||||
|     "confirm_delete_all": "Você quer excluir todas as versões desta nota?", | ||||
|     "no_revisions": "Ainda não há versões para esta nota...", | ||||
|     "restore_button": "Recuperar", | ||||
|   | ||||
| @@ -1090,7 +1090,6 @@ | ||||
|     "preview_not_available": "Nu este disponibilă o previzualizare pentru acest tip de notiță.", | ||||
|     "restore_button": "Restaurează", | ||||
|     "revision_deleted": "Revizia notiței a fost ștearsă.", | ||||
|     "revision_last_edited": "Revizia a fost ultima oară modificată pe {{date}}", | ||||
|     "revision_restored": "Revizia notiței a fost restaurată.", | ||||
|     "revisions_deleted": "Notița reviziei a fost ștearsă.", | ||||
|     "maximum_revisions": "Numărul maxim de revizii pentru notița curentă: {{number}}.", | ||||
|   | ||||
| @@ -366,7 +366,6 @@ | ||||
|     "delete_all_button": "Удалить все версии", | ||||
|     "help_title": "Помощь по версиям заметок", | ||||
|     "confirm_delete_all": "Вы хотите удалить все версии этой заметки?", | ||||
|     "revision_last_edited": "Эта версия последний раз редактировалась {{date}}", | ||||
|     "confirm_restore": "Хотите восстановить эту версию? Текущее название и содержание заметки будут перезаписаны этой версией.", | ||||
|     "confirm_delete": "Вы хотите удалить эту версию?", | ||||
|     "revisions_deleted": "Версии заметки были удалены.", | ||||
|   | ||||
| @@ -256,7 +256,6 @@ | ||||
|         "delete_all_revisions": "Obriši sve revizije ove beleške", | ||||
|         "delete_all_button": "Obriši sve revizije", | ||||
|         "help_title": "Pomoć za Revizije beleški", | ||||
|         "revision_last_edited": "Ova revizija je poslednji put izmenjena {{date}}", | ||||
|         "confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?", | ||||
|         "no_revisions": "Još uvek nema revizija za ovu belešku...", | ||||
|         "restore_button": "Vrati", | ||||
|   | ||||
| @@ -260,7 +260,6 @@ | ||||
|     "delete_all_revisions": "刪除此筆記的所有歷史版本", | ||||
|     "delete_all_button": "刪除所有歷史版本", | ||||
|     "help_title": "關於筆記歷史版本的說明", | ||||
|     "revision_last_edited": "此歷史版本上次於 {{date}} 編輯", | ||||
|     "confirm_delete_all": "您是否要刪除此筆記的所有歷史版本?", | ||||
|     "no_revisions": "此筆記暫無歷史版本…", | ||||
|     "confirm_restore": "您是否要還原此歷史版本?這將使用此歷史版本覆寫筆記的目前標題和內容。", | ||||
|   | ||||
| @@ -309,7 +309,6 @@ | ||||
|     "delete_all_revisions": "Видалити всі версії цієї нотатки", | ||||
|     "delete_all_button": "Видалити всі версії", | ||||
|     "help_title": "Довідка щодо Версій нотаток", | ||||
|     "revision_last_edited": "Цю версію востаннє редагували {{date}}", | ||||
|     "confirm_delete_all": "Ви хочете видалити всі версії цієї нотатки?", | ||||
|     "no_revisions": "Поки що немає версій цієї нотатки...", | ||||
|     "restore_button": "Відновити", | ||||
|   | ||||
							
								
								
									
										1
									
								
								apps/client/src/types.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								apps/client/src/types.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -26,7 +26,6 @@ interface CustomGlobals { | ||||
|     appContext: AppContext; | ||||
|     froca: Froca; | ||||
|     treeCache: Froca; | ||||
|     importMarkdownInline: () => Promise<unknown>; | ||||
|     SEARCH_HELP_TEXT: string; | ||||
|     activeDialog: JQuery<HTMLElement> | null; | ||||
|     componentId: string; | ||||
|   | ||||
| @@ -79,7 +79,8 @@ export default function ExportDialog() { | ||||
|                         values={[ | ||||
|                             { value: "html", label: t("export.format_html_zip") }, | ||||
|                             { value: "markdown", label: t("export.format_markdown") }, | ||||
|                             { value: "opml", label: t("export.format_opml") } | ||||
|                             { value: "opml", label: t("export.format_opml") }, | ||||
|                             { value: "share", label: t("export.share-format") } | ||||
|                         ]} | ||||
|                     /> | ||||
|  | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import utils from "../../services/utils"; | ||||
| import Modal from "../react/Modal"; | ||||
| import Button from "../react/Button"; | ||||
| import { useTriliumEvent } from "../react/hooks"; | ||||
| import EditableTextTypeWidget from "../type_widgets/editable_text"; | ||||
|  | ||||
| interface RenderMarkdownResponse { | ||||
|     htmlContent: string; | ||||
| @@ -14,39 +15,34 @@ interface RenderMarkdownResponse { | ||||
|  | ||||
| export default function MarkdownImportDialog() { | ||||
|     const markdownImportTextArea = useRef<HTMLTextAreaElement>(null); | ||||
|     const [textTypeWidget, setTextTypeWidget] = useState<EditableTextTypeWidget>(); | ||||
|     const [ text, setText ] = useState(""); | ||||
|     const [ shown, setShown ] = useState(false); | ||||
|  | ||||
|     const triggerImport = useCallback(() => { | ||||
|         if (appContext.tabManager.getActiveContextNoteType() !== "text") { | ||||
|             return; | ||||
|         } | ||||
|      | ||||
|     useTriliumEvent("showPasteMarkdownDialog", ({ textTypeWidget }) => { | ||||
|         setTextTypeWidget(textTypeWidget); | ||||
|         if (utils.isElectron()) { | ||||
|             const { clipboard } = utils.dynamicRequire("electron"); | ||||
|             const text = clipboard.readText(); | ||||
|      | ||||
|             convertMarkdownToHtml(text); | ||||
|             convertMarkdownToHtml(text, textTypeWidget); | ||||
|         } else { | ||||
|             setShown(true); | ||||
|         } | ||||
|     }, []); | ||||
|  | ||||
|     useTriliumEvent("importMarkdownInline", triggerImport); | ||||
|     useTriliumEvent("pasteMarkdownIntoText", triggerImport); | ||||
|  | ||||
|     async function sendForm() { | ||||
|         await convertMarkdownToHtml(text); | ||||
|         setText(""); | ||||
|         setShown(false); | ||||
|     } | ||||
|     }); | ||||
|  | ||||
|     return ( | ||||
|         <Modal | ||||
|             className="markdown-import-dialog" title={t("markdown_import.dialog_title")} size="lg" | ||||
|             footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={sendForm} keyboardShortcut="Ctrl+Space" />} | ||||
|             footer={<Button className="markdown-import-button" text={t("markdown_import.import_button")} onClick={() => setShown(false)} keyboardShortcut="Ctrl+Enter" />} | ||||
|             onShown={() => markdownImportTextArea.current?.focus()} | ||||
|             onHidden={() => setShown(false) } | ||||
|             onHidden={async () => { | ||||
|                 if (textTypeWidget) { | ||||
|                     await convertMarkdownToHtml(text, textTypeWidget); | ||||
|                 } | ||||
|                 setShown(false); | ||||
|                 setText(""); | ||||
|             }} | ||||
|             show={shown} | ||||
|         > | ||||
|             <p>{t("markdown_import.modal_body_text")}</p> | ||||
| @@ -56,26 +52,17 @@ export default function MarkdownImportDialog() { | ||||
|                 onKeyDown={(e) => { | ||||
|                     if (e.key === "Enter" && e.ctrlKey) { | ||||
|                         e.preventDefault(); | ||||
|                         sendForm(); | ||||
|                         setShown(false); | ||||
|                     } | ||||
|                 }}></textarea> | ||||
|         </Modal> | ||||
|     ) | ||||
| } | ||||
|  | ||||
| async function convertMarkdownToHtml(markdownContent: string) { | ||||
| async function convertMarkdownToHtml(markdownContent: string, textTypeWidget: EditableTextTypeWidget) { | ||||
|     const { htmlContent } = await server.post<RenderMarkdownResponse>("other/render-markdown", { markdownContent }); | ||||
|  | ||||
|     const textEditor = await appContext.tabManager.getActiveContext()?.getTextEditor(); | ||||
|     if (!textEditor) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const viewFragment = textEditor.data.processor.toView(htmlContent); | ||||
|     const modelFragment = textEditor.data.toModel(viewFragment); | ||||
|  | ||||
|     textEditor.model.insertContent(modelFragment, textEditor.model.document.selection); | ||||
|     textEditor.editing.view.focus(); | ||||
|  | ||||
|     await textTypeWidget.addHtmlToEditor(htmlContent); | ||||
|      | ||||
|     toast.showMessage(t("markdown_import.import_success")); | ||||
| } | ||||
| @@ -155,6 +155,11 @@ export default class PopupEditorDialog extends Container<BasicWidget> { | ||||
|             return Promise.resolve(); | ||||
|         } | ||||
|  | ||||
|         // Avoid not showing recent notes when creating a new empty tab. | ||||
|         if ("noteContext" in data && data.noteContext.ntxId !== "_popup-editor") { | ||||
|             return Promise.resolve(); | ||||
|         } | ||||
|  | ||||
|         return super.handleEventInChildren(name, data); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -140,11 +140,10 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re | ||||
|         <FormList onSelect={onSelect} fullHeight> | ||||
|             {revisions.map((item) => | ||||
|                 <FormListItem | ||||
|                     title={t("revisions.revision_last_edited", { date: item.dateLastEdited })} | ||||
|                     value={item.revisionId} | ||||
|                     active={currentRevision && item.revisionId === currentRevision.revisionId} | ||||
|                 > | ||||
|                     {item.dateLastEdited && item.dateLastEdited.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)}) | ||||
|                     {item.dateCreated && item.dateCreated.substr(0, 16)} ({item.contentLength && utils.formatSize(item.contentLength)}) | ||||
|                 </FormListItem> | ||||
|             )} | ||||
|         </FormList>); | ||||
|   | ||||
| @@ -264,7 +264,6 @@ | ||||
|     position: absolute; | ||||
|     inset-inline-end: 5px; | ||||
|     bottom: 5px; | ||||
|     z-index: 1000; | ||||
| } | ||||
|  | ||||
| .style-resolver { | ||||
|   | ||||
| @@ -329,6 +329,30 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async addHtmlToEditor(html: string) { | ||||
|         await this.initialized; | ||||
|  | ||||
|         const editor = this.watchdog.editor; | ||||
|         if (!editor) return; | ||||
|  | ||||
|         editor.model.change((writer) => { | ||||
|             const viewFragment = editor.data.processor.toView(html);  | ||||
|             const modelFragment = editor.data.toModel(viewFragment);  | ||||
|             const insertPosition = editor.model.document.selection.getLastPosition(); | ||||
|  | ||||
|             if (insertPosition) { | ||||
|                 const range = editor.model.insertContent(modelFragment, insertPosition); | ||||
|  | ||||
|                 if (range) { | ||||
|                     writer.setSelection(range.end); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|         }); | ||||
|          | ||||
|         editor.editing.view.focus(); | ||||
|     } | ||||
|  | ||||
|     addTextToActiveEditorEvent({ text }: EventData<"addTextToActiveEditor">) { | ||||
|         if (!this.isActive()) { | ||||
|             return; | ||||
| @@ -385,6 +409,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { | ||||
|         this.triggerCommand("showAddLinkDialog", { textTypeWidget: this, text: selectedText }); | ||||
|     } | ||||
|  | ||||
|     pasteMarkdownIntoTextCommand() { | ||||
|         this.triggerCommand("showPasteMarkdownDialog", { textTypeWidget: this }); | ||||
|     } | ||||
|  | ||||
|     getSelectedText() { | ||||
|         const range = this.watchdog.editor?.model.document.selection.getFirstRange(); | ||||
|         let text = ""; | ||||
|   | ||||
| @@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1 | ||||
| IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL | ||||
|  | ||||
| :POWERSHELL | ||||
| powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:NODE_TLS_REJECT_UNAUTHORIZED -Value 0; ./trilium.exe" | ||||
| powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:NODE_TLS_REJECT_UNAUTHORIZED -Value 0; ./trilium.exe" | ||||
| GOTO END | ||||
|  | ||||
| :BATCH | ||||
|   | ||||
| @@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1 | ||||
| IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL | ||||
|  | ||||
| :POWERSHELL | ||||
| powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:TRILIUM_DATA_DIR -Value './trilium-data'; ./trilium.exe" | ||||
| powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:TRILIUM_DATA_DIR -Value './trilium-data'; ./trilium.exe" | ||||
| GOTO END | ||||
|  | ||||
| :BATCH | ||||
|   | ||||
| @@ -6,7 +6,7 @@ WHERE powershell.exe > NUL 2>&1 | ||||
| IF %ERRORLEVEL% NEQ 0 GOTO BATCH ELSE GOTO POWERSHELL | ||||
|  | ||||
| :POWERSHELL | ||||
| powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo "Set-Item -Path Env:TRILIUM_SAFE_MODE -Value 1; ./trilium.exe --disable-gpu" | ||||
| powershell -ExecutionPolicy Bypass -NonInteractive -NoLogo -Command "Set-Item -Path Env:TRILIUM_SAFE_MODE -Value 1; ./trilium.exe --disable-gpu" | ||||
| GOTO END | ||||
|  | ||||
| :BATCH | ||||
|   | ||||
| @@ -11,6 +11,7 @@ async function main() { | ||||
|     // Copy assets. | ||||
|     build.copy("src/assets", "assets/"); | ||||
|     build.copy("/apps/server/src/assets", "assets/"); | ||||
|     build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/"); | ||||
|     build.copy("/packages/share-theme/src/templates", "share-theme/templates/"); | ||||
|  | ||||
|     // Copy node modules dependencies | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js | ||||
| import debounce from "@triliumnext/client/src/services/debounce.js"; | ||||
| import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js"; | ||||
| import cls from "@triliumnext/server/src/services/cls.js"; | ||||
| import type { AdvancedExportOptions } from "@triliumnext/server/src/services/export/zip.js"; | ||||
| import type { AdvancedExportOptions, ExportFormat } from "@triliumnext/server/src/services/export/zip/abstract_provider.js"; | ||||
| import { parseNoteMetaFile } from "@triliumnext/server/src/services/in_app_help.js"; | ||||
| import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js"; | ||||
|  | ||||
| @@ -75,7 +75,7 @@ async function setOptions() { | ||||
|     optionsService.setOption("compressImages", "false"); | ||||
| } | ||||
|  | ||||
| async function exportData(noteId: string, format: "html" | "markdown", outputPath: string, ignoredFiles?: Set<string>) { | ||||
| async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set<string>) { | ||||
|     const zipFilePath = "output.zip"; | ||||
|  | ||||
|     try { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM node:22.21.0-bullseye-slim AS builder | ||||
| FROM node:24.10.0-bullseye-slim AS builder | ||||
| RUN corepack enable | ||||
|  | ||||
| # Install native dependencies since we might be building cross-platform. | ||||
| @@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/ | ||||
| # We have to use --no-frozen-lockfile due to CKEditor patches | ||||
| RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild | ||||
|  | ||||
| FROM node:22.21.0-bullseye-slim | ||||
| FROM node:24.10.0-bullseye-slim | ||||
| # Install only runtime dependencies | ||||
| RUN apt-get update && \ | ||||
|     apt-get install -y --no-install-recommends \ | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM node:22.21.0-alpine AS builder | ||||
| FROM node:24.10.0-alpine AS builder | ||||
| RUN corepack enable | ||||
|  | ||||
| # Install native dependencies since we might be building cross-platform. | ||||
| @@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/ | ||||
| # We have to use --no-frozen-lockfile due to CKEditor patches | ||||
| RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild | ||||
|  | ||||
| FROM node:22.21.0-alpine | ||||
| FROM node:24.10.0-alpine | ||||
| # Install runtime dependencies | ||||
| RUN apk add --no-cache su-exec shadow | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM node:22.21.0-alpine AS builder | ||||
| FROM node:24.10.0-alpine AS builder | ||||
| RUN corepack enable | ||||
|  | ||||
| # Install native dependencies since we might be building cross-platform. | ||||
| @@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/ | ||||
| # We have to use --no-frozen-lockfile due to CKEditor patches | ||||
| RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild | ||||
|  | ||||
| FROM node:22.21.0-alpine | ||||
| FROM node:24.10.0-alpine | ||||
| # Create a non-root user with configurable UID/GID | ||||
| ARG USER=trilium | ||||
| ARG UID=1001 | ||||
|   | ||||
							
								
								
									
										28
									
								
								apps/server/Dockerfile.legacy
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								apps/server/Dockerfile.legacy
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| FROM node:22.21.0-bullseye-slim AS builder | ||||
| RUN corepack enable | ||||
|  | ||||
| # Install native dependencies since we might be building cross-platform. | ||||
| WORKDIR /usr/src/app/build | ||||
| COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/ | ||||
| # We have to use --no-frozen-lockfile due to CKEditor patches | ||||
| RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild | ||||
|  | ||||
| FROM node:24.10.0-bullseye-slim | ||||
| # Install only runtime dependencies | ||||
| RUN apt-get update && \ | ||||
|     apt-get install -y --no-install-recommends \ | ||||
|     gosu && \ | ||||
|     rm -rf \ | ||||
|     /var/lib/apt/lists/* \ | ||||
|     /var/cache/apt/* | ||||
|  | ||||
| WORKDIR /usr/src/app | ||||
| COPY ./dist /usr/src/app | ||||
| RUN rm -rf /usr/src/app/node_modules/better-sqlite3 | ||||
| COPY --from=builder /usr/src/app/node_modules/better-sqlite3 /usr/src/app/node_modules/better-sqlite3 | ||||
| COPY ./start-docker.sh /usr/src/app | ||||
|  | ||||
| # Configure container | ||||
| EXPOSE 8080 | ||||
| CMD [ "sh", "./start-docker.sh" ] | ||||
| HEALTHCHECK --start-period=10s CMD exec gosu node node /usr/src/app/docker_healthcheck.cjs | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM node:22.21.0-bullseye-slim AS builder | ||||
| FROM node:24.10.0-bullseye-slim AS builder | ||||
| RUN corepack enable | ||||
|  | ||||
| # Install native dependencies since we might be building cross-platform. | ||||
| @@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/ | ||||
| # We have to use --no-frozen-lockfile due to CKEditor patches | ||||
| RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild | ||||
|  | ||||
| FROM node:22.21.0-bullseye-slim | ||||
| FROM node:24.10.0-bullseye-slim | ||||
| # Create a non-root user with configurable UID/GID | ||||
| ARG USER=trilium | ||||
| ARG UID=1001 | ||||
|   | ||||
| @@ -36,7 +36,8 @@ | ||||
|     "@triliumnext/commons": "workspace:*", | ||||
|     "@triliumnext/express-partial-content": "workspace:*", | ||||
|     "@triliumnext/turndown-plugin-gfm": "workspace:*", | ||||
|     "@types/archiver": "6.0.4", | ||||
|     "@triliumnext/highlightjs": "workspace:*", | ||||
|     "@types/archiver": "7.0.0", | ||||
|     "@types/better-sqlite3": "7.6.13", | ||||
|     "@types/cls-hooked": "4.3.9", | ||||
|     "@types/compression": "1.8.1", | ||||
| @@ -56,17 +57,17 @@ | ||||
|     "@types/sanitize-html": "2.16.0", | ||||
|     "@types/sax": "1.2.7", | ||||
|     "@types/serve-favicon": "2.5.7", | ||||
|     "@types/serve-static": "1.15.10", | ||||
|     "@types/serve-static": "2.2.0", | ||||
|     "@types/stream-throttle": "0.1.4", | ||||
|     "@types/supertest": "6.0.3", | ||||
|     "@types/swagger-ui-express": "4.1.8", | ||||
|     "@types/tmp": "0.2.6", | ||||
|     "@types/turndown": "5.0.5", | ||||
|     "@types/turndown": "5.0.6", | ||||
|     "@types/ws": "8.18.1", | ||||
|     "@types/xml2js": "0.4.14", | ||||
|     "archiver": "7.0.1", | ||||
|     "async-mutex": "0.5.0", | ||||
|     "axios": "1.12.2", | ||||
|     "axios": "1.13.0", | ||||
|     "bindings": "1.5.0", | ||||
|     "bootstrap": "5.3.8", | ||||
|     "chardet": "2.1.0", | ||||
|   | ||||
| @@ -7,6 +7,7 @@ async function main() { | ||||
|  | ||||
|     // Copy assets | ||||
|     build.copy("src/assets", "assets/"); | ||||
|     build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/"); | ||||
|     build.copy("/packages/share-theme/src/templates", "share-theme/templates/"); | ||||
|  | ||||
|     // Copy node modules dependencies | ||||
|   | ||||
| @@ -278,6 +278,11 @@ class BBranch extends AbstractBeccaEntity<BBranch> { | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     getParentNote() { | ||||
|         return this.parentNote; | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| export default BBranch; | ||||
|   | ||||
| @@ -1758,6 +1758,26 @@ class BNote extends AbstractBeccaEntity<BNote> { | ||||
|         return childBranches; | ||||
|     } | ||||
|  | ||||
|     get encodedTitle() { | ||||
|         return encodeURIComponent(this.title); | ||||
|     } | ||||
|  | ||||
|     getVisibleChildBranches() { | ||||
|         return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree")); | ||||
|     } | ||||
|  | ||||
|     getVisibleChildNotes() { | ||||
|         return this.getVisibleChildBranches().map((branch) => branch.getNote()); | ||||
|     } | ||||
|  | ||||
|     hasVisibleChildren() { | ||||
|         return this.getVisibleChildNotes().length > 0; | ||||
|     } | ||||
|  | ||||
|     get shareId() { | ||||
|         return this.noteId; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Return an attribute by it's attributeId.  Requires the attribute cache to be available. | ||||
|      * @param attributeId - the id of the attribute owned by this note | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import type { ParsedQs } from "qs"; | ||||
| import type { NoteParams } from "../services/note-interface.js"; | ||||
| import type { SearchParams } from "../services/search/services/types.js"; | ||||
| import type { ValidatorMap } from "./etapi-interface.js"; | ||||
| import type { ExportFormat } from "../services/export/zip/abstract_provider.js"; | ||||
|  | ||||
| function register(router: Router) { | ||||
|     eu.route(router, "get", "/etapi/notes", (req, res, next) => { | ||||
| @@ -149,8 +150,8 @@ function register(router: Router) { | ||||
|         const note = eu.getAndCheckNote(req.params.noteId); | ||||
|         const format = req.query.format || "html"; | ||||
|  | ||||
|         if (typeof format !== "string" || !["html", "markdown"].includes(format)) { | ||||
|             throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`); | ||||
|         if (typeof format !== "string" || !["html", "markdown", "share"].includes(format)) { | ||||
|             throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default), 'markdown' or 'share'.`); | ||||
|         } | ||||
|  | ||||
|         const taskContext = new TaskContext("no-progress-reporting", "export", null); | ||||
| @@ -159,7 +160,7 @@ function register(router: Router) { | ||||
|         // (e.g. branchIds are not seen in UI), that we export "note export" instead. | ||||
|         const branch = note.getParentBranches()[0]; | ||||
|  | ||||
|         zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res); | ||||
|         zipExportService.exportToZip(taskContext, branch, format as ExportFormat, res); | ||||
|     }); | ||||
|  | ||||
|     eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => { | ||||
|   | ||||
| @@ -26,7 +26,7 @@ function exportBranch(req: Request, res: Response) { | ||||
|     const taskContext = new TaskContext(taskId, "export", null); | ||||
|  | ||||
|     try { | ||||
|         if (type === "subtree" && (format === "html" || format === "markdown")) { | ||||
|         if (type === "subtree" && (format === "html" || format === "markdown" || format === "share")) { | ||||
|             zipExportService.exportToZip(taskContext, branch, format, res); | ||||
|         } else if (type === "single") { | ||||
|             if (format !== "html" && format !== "markdown") { | ||||
|   | ||||
| @@ -162,7 +162,7 @@ function getEditedNotesOnDate(req: Request) { | ||||
|                     AND (noteId NOT LIKE '_%') | ||||
|             UNION ALL | ||||
|                 SELECT noteId FROM revisions | ||||
|                 WHERE revisions.dateLastEdited LIKE :date | ||||
|                 WHERE revisions.dateCreated LIKE :date | ||||
|         ) | ||||
|         ORDER BY isDeleted | ||||
|         LIMIT 50`, | ||||
|   | ||||
| @@ -44,6 +44,7 @@ async function register(app: express.Application) { | ||||
|         app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations"))); | ||||
|         app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules"))); | ||||
|     } | ||||
|     app.use(`/share/assets/`, express.static(getShareThemeAssetDir())); | ||||
|     app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images"))); | ||||
|     app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes"))); | ||||
|     app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts"))); | ||||
| @@ -51,6 +52,16 @@ async function register(app: express.Application) { | ||||
|     app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets"))); | ||||
| } | ||||
|  | ||||
| export function getShareThemeAssetDir() { | ||||
|     if (process.env.NODE_ENV === "development") { | ||||
|         const srcRoot = path.join(__dirname, "..", ".."); | ||||
|         return path.join(srcRoot, "../../packages/share-theme/dist"); | ||||
|     } else { | ||||
|         const resourceDir = getResourceDir(); | ||||
|         return path.join(resourceDir, "share-theme/assets"); | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default { | ||||
|     register | ||||
| }; | ||||
|   | ||||
| @@ -9,8 +9,9 @@ import type TaskContext from "../task_context.js"; | ||||
| import type BBranch from "../../becca/entities/bbranch.js"; | ||||
| import type { Response } from "express"; | ||||
| import type BNote from "../../becca/entities/bnote.js"; | ||||
| import type { ExportFormat } from "./zip/abstract_provider.js"; | ||||
|  | ||||
| function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) { | ||||
| function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) { | ||||
|     const note = branch.getNote(); | ||||
|  | ||||
|     if (note.type === "image" || note.type === "file") { | ||||
| @@ -33,7 +34,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f | ||||
|     taskContext.taskSucceeded(null); | ||||
| } | ||||
|  | ||||
| export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: "html" | "markdown") { | ||||
| export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) { | ||||
|     let payload, extension, mime; | ||||
|  | ||||
|     if (typeof content !== "string") { | ||||
|   | ||||
| @@ -1,12 +1,9 @@ | ||||
| "use strict"; | ||||
|  | ||||
| import html from "html"; | ||||
| import dateUtils from "../date_utils.js"; | ||||
| import path from "path"; | ||||
| import mimeTypes from "mime-types"; | ||||
| import mdService from "./markdown.js"; | ||||
| import packageInfo from "../../../package.json" with { type: "json" }; | ||||
| import { getContentDisposition, escapeHtml, getResourceDir, isDev } from "../utils.js"; | ||||
| import { getContentDisposition } from "../utils.js"; | ||||
| import protectedSessionService from "../protected_session.js"; | ||||
| import sanitize from "sanitize-filename"; | ||||
| import fs from "fs"; | ||||
| @@ -18,39 +15,48 @@ import ValidationError from "../../errors/validation_error.js"; | ||||
| import type NoteMeta from "../meta/note_meta.js"; | ||||
| import type AttachmentMeta from "../meta/attachment_meta.js"; | ||||
| import type AttributeMeta from "../meta/attribute_meta.js"; | ||||
| import type BBranch from "../../becca/entities/bbranch.js"; | ||||
| import BBranch from "../../becca/entities/bbranch.js"; | ||||
| import type { Response } from "express"; | ||||
| import type { NoteMetaFile } from "../meta/note_meta.js"; | ||||
| import HtmlExportProvider from "./zip/html.js"; | ||||
| import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js"; | ||||
| import MarkdownExportProvider from "./zip/markdown.js"; | ||||
| import ShareThemeExportProvider from "./zip/share_theme.js"; | ||||
| import type BNote from "../../becca/entities/bnote.js"; | ||||
| import { NoteType } from "@triliumnext/commons"; | ||||
|  | ||||
| type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; | ||||
|  | ||||
| export interface AdvancedExportOptions { | ||||
|     /** | ||||
|      * If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template. | ||||
|      */ | ||||
|     skipHtmlTemplate?: boolean; | ||||
|  | ||||
|     /** | ||||
|      * Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type. | ||||
|      * | ||||
|      * @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it. | ||||
|      * @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well. | ||||
|      * @returns a function to rewrite the links in HTML or Markdown notes. | ||||
|      */ | ||||
|     customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn; | ||||
| } | ||||
|  | ||||
| async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { | ||||
|     if (!["html", "markdown"].includes(format)) { | ||||
|         throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`); | ||||
| async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { | ||||
|     if (!["html", "markdown", "share"].includes(format)) { | ||||
|         throw new ValidationError(`Only 'html', 'markdown' and 'share' allowed as export format, '${format}' given`); | ||||
|     } | ||||
|  | ||||
|     const archive = archiver("zip", { | ||||
|         zlib: { level: 9 } // Sets the compression level. | ||||
|     }); | ||||
|     const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks); | ||||
|     const provider = buildProvider(); | ||||
|  | ||||
|     const noteIdToMeta: Record<string, NoteMeta> = {}; | ||||
|  | ||||
|     function buildProvider() { | ||||
|         const providerData: ZipExportProviderData = { | ||||
|             getNoteTargetUrl, | ||||
|             archive, | ||||
|             branch, | ||||
|             rewriteFn | ||||
|         }; | ||||
|         switch (format) { | ||||
|             case "html": | ||||
|                 return new HtmlExportProvider(providerData); | ||||
|             case "markdown": | ||||
|                 return new MarkdownExportProvider(providerData); | ||||
|             case "share": | ||||
|                 return new ShareThemeExportProvider(providerData); | ||||
|             default: | ||||
|                 throw new Error(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function getUniqueFilename(existingFileNames: Record<string, number>, fileName: string) { | ||||
|         const lcFileName = fileName.toLowerCase(); | ||||
|  | ||||
| @@ -72,7 +78,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string { | ||||
|     function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string { | ||||
|         let fileName = baseFileName.trim(); | ||||
|         if (!fileName) { | ||||
|             fileName = "note"; | ||||
| @@ -90,36 +96,14 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, | ||||
|         } | ||||
|  | ||||
|         let existingExtension = path.extname(fileName).toLowerCase(); | ||||
|         let newExtension; | ||||
|  | ||||
|         // the following two are handled specifically since we always want to have these extensions no matter the automatic detection | ||||
|         // and/or existing detected extensions in the note name | ||||
|         if (type === "text" && format === "markdown") { | ||||
|             newExtension = "md"; | ||||
|         } else if (type === "text" && format === "html") { | ||||
|             newExtension = "html"; | ||||
|         } else if (mime === "application/x-javascript" || mime === "text/javascript") { | ||||
|             newExtension = "js"; | ||||
|         } else if (type === "canvas" || mime === "application/json") { | ||||
|             newExtension = "json"; | ||||
|         } else if (existingExtension.length > 0) { | ||||
|             // if the page already has an extension, then we'll just keep it | ||||
|             newExtension = null; | ||||
|         } else { | ||||
|             if (mime?.toLowerCase()?.trim() === "image/jpg") { | ||||
|                 newExtension = "jpg"; | ||||
|             } else if (mime?.toLowerCase()?.trim() === "text/mermaid") { | ||||
|                 newExtension = "txt"; | ||||
|             } else { | ||||
|                 newExtension = mimeTypes.extension(mime) || "dat"; | ||||
|             } | ||||
|         } | ||||
|         const newExtension = provider.mapExtension(type, mime, existingExtension, format); | ||||
|  | ||||
|         // if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again | ||||
|         if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) { | ||||
|             fileName += `.${newExtension}`; | ||||
|         } | ||||
|  | ||||
|  | ||||
|         return getUniqueFilename(existingFileNames, fileName); | ||||
|     } | ||||
|  | ||||
| @@ -145,7 +129,8 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, | ||||
|         const notePath = parentMeta.notePath.concat([note.noteId]); | ||||
|  | ||||
|         if (note.noteId in noteIdToMeta) { | ||||
|             const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === "html" ? "html" : "md"}`); | ||||
|             const extension = provider.mapExtension("text", "text/html", "", format); | ||||
|             const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${extension}`); | ||||
|  | ||||
|             const meta: NoteMeta = { | ||||
|                 isClone: true, | ||||
| @@ -155,7 +140,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, | ||||
|                 prefix: branch.prefix, | ||||
|                 dataFileName: fileName, | ||||
|                 type: "text", // export will have text description | ||||
|                 format: format | ||||
|                 format: (format === "markdown" ? "markdown" : "html") | ||||
|             }; | ||||
|             return meta; | ||||
|         } | ||||
| @@ -185,7 +170,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, | ||||
|         taskContext.increaseProgressCount(); | ||||
|  | ||||
|         if (note.type === "text") { | ||||
|             meta.format = format; | ||||
|             meta.format = (format === "markdown" ? "markdown" : "html"); | ||||
|         } | ||||
|  | ||||
|         noteIdToMeta[note.noteId] = meta as NoteMeta; | ||||
| @@ -194,10 +179,13 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, | ||||
|         note.sortChildren(); | ||||
|         const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden"); | ||||
|  | ||||
|         const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable(); | ||||
|         let shouldIncludeFile = (!note.isProtected || protectedSessionService.isProtectedSessionAvailable()); | ||||
|         if (format !== "share") { | ||||
|             shouldIncludeFile = shouldIncludeFile && (note.getContent().length > 0 || childBranches.length === 0); | ||||
|         } | ||||
|  | ||||
|         // if it's a leaf, then we'll export it even if it's empty | ||||
|         if (available && (note.getContent().length > 0 || childBranches.length === 0)) { | ||||
|         if (shouldIncludeFile) { | ||||
|             meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames); | ||||
|         } | ||||
|  | ||||
| @@ -273,8 +261,6 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, | ||||
|         return url; | ||||
|     } | ||||
|  | ||||
|     const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks); | ||||
|  | ||||
|     function rewriteLinks(content: string, noteMeta: NoteMeta): string { | ||||
|         content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => { | ||||
|             const url = getNoteTargetUrl(targetNoteId, noteMeta); | ||||
| @@ -316,53 +302,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { | ||||
|         if (["html", "markdown"].includes(noteMeta?.format || "")) { | ||||
|     function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer { | ||||
|         const isText = ["html", "markdown"].includes(noteMeta?.format || ""); | ||||
|         if (isText) { | ||||
|             content = content.toString(); | ||||
|             content = rewriteFn(content, noteMeta); | ||||
|         } | ||||
|  | ||||
|         if (noteMeta.format === "html" && typeof content === "string") { | ||||
|             if (!content.substr(0, 100).toLowerCase().includes("<html") && !zipExportOptions?.skipHtmlTemplate) { | ||||
|                 if (!noteMeta?.notePath?.length) { | ||||
|                     throw new Error("Missing note path."); | ||||
|                 } | ||||
|         content = provider.prepareContent(title, content, noteMeta, note, branch); | ||||
|  | ||||
|                 const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`; | ||||
|                 const htmlTitle = escapeHtml(title); | ||||
|  | ||||
|                 // <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 | ||||
|                 content = `<html> | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
|     <link rel="stylesheet" href="${cssUrl}"> | ||||
|     <base target="_parent"> | ||||
|     <title data-trilium-title>${htmlTitle}</title> | ||||
| </head> | ||||
| <body> | ||||
|   <div class="content"> | ||||
|     <h1 data-trilium-h1>${htmlTitle}</h1> | ||||
|  | ||||
|     <div class="ck-content">${content}</div> | ||||
|   </div> | ||||
| </body> | ||||
| </html>`; | ||||
|             } | ||||
|  | ||||
|             return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content; | ||||
|         } else if (noteMeta.format === "markdown" && typeof content === "string") { | ||||
|             let markdownContent = mdService.toMarkdown(content); | ||||
|  | ||||
|             if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) { | ||||
|                 markdownContent = `# ${title}\r | ||||
| ${markdownContent}`; | ||||
|             } | ||||
|  | ||||
|             return markdownContent; | ||||
|         } else { | ||||
|             return content; | ||||
|         } | ||||
|         return content; | ||||
|     } | ||||
|  | ||||
|     function saveNote(noteMeta: NoteMeta, filePathPrefix: string) { | ||||
| @@ -377,7 +325,7 @@ ${markdownContent}`; | ||||
|  | ||||
|             let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`; | ||||
|  | ||||
|             content = prepareContent(noteMeta.title, content, noteMeta); | ||||
|             content = prepareContent(noteMeta.title, content, noteMeta, undefined); | ||||
|  | ||||
|             archive.append(content, { name: filePathPrefix + noteMeta.dataFileName }); | ||||
|  | ||||
| @@ -393,7 +341,7 @@ ${markdownContent}`; | ||||
|         } | ||||
|  | ||||
|         if (noteMeta.dataFileName) { | ||||
|             const content = prepareContent(noteMeta.title, note.getContent(), noteMeta); | ||||
|             const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note); | ||||
|  | ||||
|             archive.append(content, { | ||||
|                 name: filePathPrefix + noteMeta.dataFileName, | ||||
| @@ -429,138 +377,21 @@ ${markdownContent}`; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) { | ||||
|         if (!navigationMeta.dataFileName) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         function saveNavigationInner(meta: NoteMeta) { | ||||
|             let html = "<li>"; | ||||
|  | ||||
|             const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`); | ||||
|  | ||||
|             if (meta.dataFileName && meta.noteId) { | ||||
|                 const targetUrl = getNoteTargetUrl(meta.noteId, rootMeta); | ||||
|  | ||||
|                 html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`; | ||||
|             } else { | ||||
|                 html += escapedTitle; | ||||
|             } | ||||
|  | ||||
|             if (meta.children && meta.children.length > 0) { | ||||
|                 html += "<ul>"; | ||||
|  | ||||
|                 for (const child of meta.children) { | ||||
|                     html += saveNavigationInner(child); | ||||
|                 } | ||||
|  | ||||
|                 html += "</ul>"; | ||||
|             } | ||||
|  | ||||
|             return `${html}</li>`; | ||||
|         } | ||||
|  | ||||
|         const fullHtml = `<html> | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <link rel="stylesheet" href="style.css"> | ||||
| </head> | ||||
| <body> | ||||
|     <ul>${saveNavigationInner(rootMeta)}</ul> | ||||
| </body> | ||||
| </html>`; | ||||
|         const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml; | ||||
|  | ||||
|         archive.append(prettyHtml, { name: navigationMeta.dataFileName }); | ||||
|     const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {}; | ||||
|     const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames); | ||||
|     if (!rootMeta) { | ||||
|         throw new Error("Unable to create root meta."); | ||||
|     } | ||||
|  | ||||
|     function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) { | ||||
|         let firstNonEmptyNote; | ||||
|         let curMeta = rootMeta; | ||||
|     const metaFile: NoteMetaFile = { | ||||
|         formatVersion: 2, | ||||
|         appVersion: packageInfo.version, | ||||
|         files: [rootMeta] | ||||
|     }; | ||||
|  | ||||
|         if (!indexMeta.dataFileName) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         while (!firstNonEmptyNote) { | ||||
|             if (curMeta.dataFileName && curMeta.noteId) { | ||||
|                 firstNonEmptyNote = getNoteTargetUrl(curMeta.noteId, rootMeta); | ||||
|             } | ||||
|  | ||||
|             if (curMeta.children && curMeta.children.length > 0) { | ||||
|                 curMeta = curMeta.children[0]; | ||||
|             } else { | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | ||||
| <html> | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
| </head> | ||||
| <frameset cols="25%,75%"> | ||||
|     <frame name="navigation" src="navigation.html"> | ||||
|     <frame name="detail" src="${firstNonEmptyNote}"> | ||||
| </frameset> | ||||
| </html>`; | ||||
|  | ||||
|         archive.append(fullHtml, { name: indexMeta.dataFileName }); | ||||
|     } | ||||
|  | ||||
|     function saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) { | ||||
|         if (!cssMeta.dataFileName) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const cssFile = isDev | ||||
|             ? path.join(__dirname, "../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css") | ||||
|             : path.join(getResourceDir(), "ckeditor5-content.css"); | ||||
|  | ||||
|         archive.append(fs.readFileSync(cssFile, "utf-8"), { name: cssMeta.dataFileName }); | ||||
|     } | ||||
|     provider.prepareMeta(metaFile); | ||||
|  | ||||
|     try { | ||||
|         const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {}; | ||||
|         const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames); | ||||
|         if (!rootMeta) { | ||||
|             throw new Error("Unable to create root meta."); | ||||
|         } | ||||
|  | ||||
|         const metaFile: NoteMetaFile = { | ||||
|             formatVersion: 2, | ||||
|             appVersion: packageInfo.version, | ||||
|             files: [rootMeta] | ||||
|         }; | ||||
|  | ||||
|         let navigationMeta: NoteMeta | null = null; | ||||
|         let indexMeta: NoteMeta | null = null; | ||||
|         let cssMeta: NoteMeta | null = null; | ||||
|  | ||||
|         if (format === "html") { | ||||
|             navigationMeta = { | ||||
|                 noImport: true, | ||||
|                 dataFileName: "navigation.html" | ||||
|             }; | ||||
|  | ||||
|             metaFile.files.push(navigationMeta); | ||||
|  | ||||
|             indexMeta = { | ||||
|                 noImport: true, | ||||
|                 dataFileName: "index.html" | ||||
|             }; | ||||
|  | ||||
|             metaFile.files.push(indexMeta); | ||||
|  | ||||
|             cssMeta = { | ||||
|                 noImport: true, | ||||
|                 dataFileName: "style.css" | ||||
|             }; | ||||
|  | ||||
|             metaFile.files.push(cssMeta); | ||||
|         } | ||||
|  | ||||
|         for (const noteMeta of Object.values(noteIdToMeta)) { | ||||
|             // filter out relations which are not inside this export | ||||
|             noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => { | ||||
| @@ -584,34 +415,6 @@ ${markdownContent}`; | ||||
|             } | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const metaFileJson = JSON.stringify(metaFile, null, "\t"); | ||||
|  | ||||
|         archive.append(metaFileJson, { name: "!!!meta.json" }); | ||||
|  | ||||
|         saveNote(rootMeta, ""); | ||||
|  | ||||
|         if (format === "html") { | ||||
|             if (!navigationMeta || !indexMeta || !cssMeta) { | ||||
|                 throw new Error("Missing meta."); | ||||
|             } | ||||
|  | ||||
|             saveNavigation(rootMeta, navigationMeta); | ||||
|             saveIndex(rootMeta, indexMeta); | ||||
|             saveCss(rootMeta, cssMeta); | ||||
|         } | ||||
|  | ||||
|         const note = branch.getNote(); | ||||
|         const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected() || "note"}.zip`; | ||||
|  | ||||
|         if (setHeaders && "setHeader" in res) { | ||||
|             res.setHeader("Content-Disposition", getContentDisposition(zipFileName)); | ||||
|             res.setHeader("Content-Type", "application/zip"); | ||||
|         } | ||||
|  | ||||
|         archive.pipe(res); | ||||
|         await archive.finalize(); | ||||
|         taskContext.taskSucceeded(null); | ||||
|     } catch (e: unknown) { | ||||
|         const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`; | ||||
|         log.error(message); | ||||
| @@ -623,9 +426,30 @@ ${markdownContent}`; | ||||
|             res.status(500).send(message); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     const metaFileJson = JSON.stringify(metaFile, null, "\t"); | ||||
|  | ||||
|     archive.append(metaFileJson, { name: "!!!meta.json" }); | ||||
|  | ||||
|     saveNote(rootMeta, ""); | ||||
|  | ||||
|     provider.afterDone(rootMeta); | ||||
|  | ||||
|     const note = branch.getNote(); | ||||
|     const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`; | ||||
|  | ||||
|     if (setHeaders && "setHeader" in res) { | ||||
|         res.setHeader("Content-Disposition", getContentDisposition(zipFileName)); | ||||
|         res.setHeader("Content-Type", "application/zip"); | ||||
|     } | ||||
|  | ||||
|     archive.pipe(res); | ||||
|     await archive.finalize(); | ||||
|  | ||||
|     taskContext.taskSucceeded(null); | ||||
| } | ||||
|  | ||||
| async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) { | ||||
| async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) { | ||||
|     const fileOutputStream = fs.createWriteStream(zipFilePath); | ||||
|     const taskContext = new TaskContext("no-progress-reporting", "export", null); | ||||
|  | ||||
|   | ||||
							
								
								
									
										89
									
								
								apps/server/src/services/export/zip/abstract_provider.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								apps/server/src/services/export/zip/abstract_provider.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| import { Archiver } from "archiver"; | ||||
| import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js"; | ||||
| import type BNote from "../../../becca/entities/bnote.js"; | ||||
| import type BBranch from "../../../becca/entities/bbranch.js"; | ||||
| import mimeTypes from "mime-types"; | ||||
| import { NoteType } from "@triliumnext/commons"; | ||||
|  | ||||
| type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; | ||||
|  | ||||
| export type ExportFormat = "html" | "markdown" | "share"; | ||||
|  | ||||
| export interface AdvancedExportOptions { | ||||
|     /** | ||||
|      * If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template. | ||||
|      */ | ||||
|     skipHtmlTemplate?: boolean; | ||||
|  | ||||
|     /** | ||||
|      * Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type. | ||||
|      * | ||||
|      * @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it. | ||||
|      * @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well. | ||||
|      * @returns a function to rewrite the links in HTML or Markdown notes. | ||||
|      */ | ||||
|     customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn; | ||||
| } | ||||
|  | ||||
| export interface ZipExportProviderData { | ||||
|     branch: BBranch; | ||||
|     getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; | ||||
|     archive: Archiver; | ||||
|     zipExportOptions?: AdvancedExportOptions; | ||||
|     rewriteFn: RewriteLinksFn; | ||||
| } | ||||
|  | ||||
| export abstract class ZipExportProvider { | ||||
|     branch: BBranch; | ||||
|     getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; | ||||
|     archive: Archiver; | ||||
|     zipExportOptions?: AdvancedExportOptions; | ||||
|     rewriteFn: RewriteLinksFn; | ||||
|  | ||||
|     constructor(data: ZipExportProviderData) { | ||||
|         this.branch = data.branch; | ||||
|         this.getNoteTargetUrl = data.getNoteTargetUrl; | ||||
|         this.archive = data.archive; | ||||
|         this.zipExportOptions = data.zipExportOptions; | ||||
|         this.rewriteFn = data.rewriteFn; | ||||
|     } | ||||
|  | ||||
|     abstract prepareMeta(metaFile: NoteMetaFile): void; | ||||
|     abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer; | ||||
|     abstract afterDone(rootMeta: NoteMeta): void; | ||||
|  | ||||
|     /** | ||||
|      * Determines the extension of the resulting file for a specific note type. | ||||
|      * | ||||
|      * @param type the type of the note. | ||||
|      * @param mime the mime type of the note. | ||||
|      * @param existingExtension the existing extension, including the leading period character. | ||||
|      * @param format the format requested for export (e.g. HTML, Markdown). | ||||
|      * @returns an extension *without* the leading period character, or `null` to preserve the existing extension instead. | ||||
|      */ | ||||
|     mapExtension(type: NoteType | null, mime: string, existingExtension: string, format: ExportFormat) { | ||||
|         // the following two are handled specifically since we always want to have these extensions no matter the automatic detection | ||||
|         // and/or existing detected extensions in the note name | ||||
|         if (type === "text" && format === "markdown") { | ||||
|             return "md"; | ||||
|         } else if (type === "text" && format === "html") { | ||||
|             return "html"; | ||||
|         } else if (mime === "application/x-javascript" || mime === "text/javascript") { | ||||
|             return "js"; | ||||
|         } else if (type === "canvas" || mime === "application/json") { | ||||
|             return "json"; | ||||
|         } else if (existingExtension.length > 0) { | ||||
|             // if the page already has an extension, then we'll just keep it | ||||
|             return null; | ||||
|         } else { | ||||
|             if (mime?.toLowerCase()?.trim() === "image/jpg") { | ||||
|                 return "jpg"; | ||||
|             } else if (mime?.toLowerCase()?.trim() === "text/mermaid") { | ||||
|                 return "txt"; | ||||
|             } else { | ||||
|                 return mimeTypes.extension(mime) || "dat"; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										176
									
								
								apps/server/src/services/export/zip/html.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								apps/server/src/services/export/zip/html.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| import type NoteMeta from "../../meta/note_meta.js"; | ||||
| import { escapeHtml, getResourceDir, isDev } from "../../utils"; | ||||
| import html from "html"; | ||||
| import { ZipExportProvider } from "./abstract_provider.js"; | ||||
| import path from "path"; | ||||
| import fs from "fs"; | ||||
|  | ||||
| export default class HtmlExportProvider extends ZipExportProvider { | ||||
|  | ||||
|     private navigationMeta: NoteMeta | null = null; | ||||
|     private indexMeta: NoteMeta | null = null; | ||||
|     private cssMeta: NoteMeta | null = null; | ||||
|  | ||||
|     prepareMeta(metaFile) { | ||||
|         this.navigationMeta = { | ||||
|             noImport: true, | ||||
|             dataFileName: "navigation.html" | ||||
|         }; | ||||
|         metaFile.files.push(this.navigationMeta); | ||||
|  | ||||
|         this.indexMeta = { | ||||
|             noImport: true, | ||||
|             dataFileName: "index.html" | ||||
|         }; | ||||
|         metaFile.files.push(this.indexMeta); | ||||
|  | ||||
|         this.cssMeta = { | ||||
|             noImport: true, | ||||
|             dataFileName: "style.css" | ||||
|         }; | ||||
|         metaFile.files.push(this.cssMeta); | ||||
|     } | ||||
|  | ||||
|     prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { | ||||
|         if (noteMeta.format === "html" && typeof content === "string") { | ||||
|             if (!content.substr(0, 100).toLowerCase().includes("<html") && !this.zipExportOptions?.skipHtmlTemplate) { | ||||
|                 if (!noteMeta?.notePath?.length) { | ||||
|                     throw new Error("Missing note path."); | ||||
|                 } | ||||
|  | ||||
|                 const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`; | ||||
|                 const htmlTitle = escapeHtml(title); | ||||
|  | ||||
|                 // <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 | ||||
|                 content = `<html> | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
|     <link rel="stylesheet" href="${cssUrl}"> | ||||
|     <base target="_parent"> | ||||
|     <title data-trilium-title>${htmlTitle}</title> | ||||
| </head> | ||||
| <body> | ||||
|     <div class="content"> | ||||
|     <h1 data-trilium-h1>${htmlTitle}</h1> | ||||
|  | ||||
|     <div class="ck-content">${content}</div> | ||||
|     </div> | ||||
| </body> | ||||
| </html>`; | ||||
|             } | ||||
|  | ||||
|             if (content.length < 100_000) { | ||||
|                 content = html.prettyPrint(content, { indent_size: 2 }) | ||||
|             } | ||||
|             content = this.rewriteFn(content as string, noteMeta); | ||||
|             return content; | ||||
|         } else { | ||||
|             return content; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     afterDone(rootMeta: NoteMeta) { | ||||
|         if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) { | ||||
|             throw new Error("Missing meta."); | ||||
|         } | ||||
|  | ||||
|         this.#saveNavigation(rootMeta, this.navigationMeta); | ||||
|         this.#saveIndex(rootMeta, this.indexMeta); | ||||
|         this.#saveCss(rootMeta, this.cssMeta); | ||||
|     } | ||||
|  | ||||
|     #saveNavigationInner(rootMeta: NoteMeta, meta: NoteMeta) { | ||||
|         let html = "<li>"; | ||||
|  | ||||
|         const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`); | ||||
|  | ||||
|         if (meta.dataFileName && meta.noteId) { | ||||
|             const targetUrl = this.getNoteTargetUrl(meta.noteId, rootMeta); | ||||
|  | ||||
|             html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`; | ||||
|         } else { | ||||
|             html += escapedTitle; | ||||
|         } | ||||
|  | ||||
|         if (meta.children && meta.children.length > 0) { | ||||
|             html += "<ul>"; | ||||
|  | ||||
|             for (const child of meta.children) { | ||||
|                 html += this.#saveNavigationInner(rootMeta, child); | ||||
|             } | ||||
|  | ||||
|             html += "</ul>"; | ||||
|         } | ||||
|  | ||||
|         return `${html}</li>`; | ||||
|     } | ||||
|  | ||||
|     #saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) { | ||||
|         if (!navigationMeta.dataFileName) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const fullHtml = `<html> | ||||
|     <head> | ||||
|         <meta charset="utf-8"> | ||||
|         <link rel="stylesheet" href="style.css"> | ||||
|     </head> | ||||
|     <body> | ||||
|         <ul>${this.#saveNavigationInner(rootMeta, rootMeta)}</ul> | ||||
|     </body> | ||||
|     </html>`; | ||||
|         const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml; | ||||
|  | ||||
|         this.archive.append(prettyHtml, { name: navigationMeta.dataFileName }); | ||||
|     } | ||||
|  | ||||
|     #saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) { | ||||
|         let firstNonEmptyNote; | ||||
|         let curMeta = rootMeta; | ||||
|  | ||||
|         if (!indexMeta.dataFileName) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         while (!firstNonEmptyNote) { | ||||
|             if (curMeta.dataFileName && curMeta.noteId) { | ||||
|                 firstNonEmptyNote = this.getNoteTargetUrl(curMeta.noteId, rootMeta); | ||||
|             } | ||||
|  | ||||
|             if (curMeta.children && curMeta.children.length > 0) { | ||||
|                 curMeta = curMeta.children[0]; | ||||
|             } else { | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | ||||
| <html> | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
| </head> | ||||
| <frameset cols="25%,75%"> | ||||
|     <frame name="navigation" src="navigation.html"> | ||||
|     <frame name="detail" src="${firstNonEmptyNote}"> | ||||
| </frameset> | ||||
| </html>`; | ||||
|  | ||||
|         this.archive.append(fullHtml, { name: indexMeta.dataFileName }); | ||||
|     } | ||||
|  | ||||
|     #saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) { | ||||
|         if (!cssMeta.dataFileName) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const cssFile = isDev | ||||
|             ? path.join(__dirname, "../../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css") | ||||
|             : path.join(getResourceDir(), "ckeditor5-content.css"); | ||||
|         const cssContent = fs.readFileSync(cssFile, "utf-8"); | ||||
|         this.archive.append(cssContent, { name: cssMeta.dataFileName }); | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
							
								
								
									
										27
									
								
								apps/server/src/services/export/zip/markdown.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								apps/server/src/services/export/zip/markdown.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import NoteMeta from "../../meta/note_meta" | ||||
| import { ZipExportProvider } from "./abstract_provider.js" | ||||
| import mdService from "../markdown.js"; | ||||
|  | ||||
| export default class MarkdownExportProvider extends ZipExportProvider { | ||||
|  | ||||
|     prepareMeta() { } | ||||
|  | ||||
|     prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { | ||||
|         if (noteMeta.format === "markdown" && typeof content === "string") { | ||||
|             let markdownContent = mdService.toMarkdown(content); | ||||
|  | ||||
|             if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) { | ||||
|                 markdownContent = `# ${title}\r | ||||
| ${markdownContent}`; | ||||
|             } | ||||
|  | ||||
|             markdownContent = this.rewriteFn(markdownContent, noteMeta); | ||||
|             return markdownContent; | ||||
|         } else { | ||||
|             return content; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     afterDone() { } | ||||
|  | ||||
| } | ||||
							
								
								
									
										115
									
								
								apps/server/src/services/export/zip/share_theme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								apps/server/src/services/export/zip/share_theme.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| import { join } from "path"; | ||||
| import NoteMeta, { NoteMetaFile } from "../../meta/note_meta"; | ||||
| import { ExportFormat, ZipExportProvider } from "./abstract_provider.js"; | ||||
| import { RESOURCE_DIR } from "../../resource_dir"; | ||||
| import { getResourceDir, isDev } from "../../utils"; | ||||
| import fs, { readdirSync } from "fs"; | ||||
| import { renderNoteForExport } from "../../../share/content_renderer"; | ||||
| import type BNote from "../../../becca/entities/bnote.js"; | ||||
| import type BBranch from "../../../becca/entities/bbranch.js"; | ||||
| import { getShareThemeAssetDir } from "../../../routes/assets"; | ||||
|  | ||||
| const shareThemeAssetDir = getShareThemeAssetDir(); | ||||
|  | ||||
| export default class ShareThemeExportProvider extends ZipExportProvider { | ||||
|  | ||||
|     private assetsMeta: NoteMeta[] = []; | ||||
|     private indexMeta: NoteMeta | null = null; | ||||
|  | ||||
|     prepareMeta(metaFile: NoteMetaFile): void { | ||||
|  | ||||
|         const assets = [ | ||||
|             "icon-color.svg" | ||||
|         ]; | ||||
|  | ||||
|         for (const file of readdirSync(shareThemeAssetDir)) { | ||||
|             assets.push(`assets/${file}`); | ||||
|         } | ||||
|  | ||||
|         for (const asset of assets) { | ||||
|             const assetMeta = { | ||||
|                 noImport: true, | ||||
|                 dataFileName: asset | ||||
|             }; | ||||
|             this.assetsMeta.push(assetMeta); | ||||
|             metaFile.files.push(assetMeta); | ||||
|         } | ||||
|  | ||||
|         this.indexMeta = { | ||||
|             noImport: true, | ||||
|             dataFileName: "index.html" | ||||
|         }; | ||||
|  | ||||
|         metaFile.files.push(this.indexMeta); | ||||
|     } | ||||
|  | ||||
|     prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer { | ||||
|         if (!noteMeta?.notePath?.length) { | ||||
|             throw new Error("Missing note path."); | ||||
|         } | ||||
|         const basePath = "../".repeat(noteMeta.notePath.length - 1); | ||||
|  | ||||
|         if (note) { | ||||
|             content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1)); | ||||
|             if (typeof content === "string") { | ||||
|                 content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, (match, id) => { | ||||
|                     if (match.includes("/assets/")) return match; | ||||
|                     return `href="#root/${id}"`; | ||||
|                 }); | ||||
|                 content = this.rewriteFn(content, noteMeta); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return content; | ||||
|     } | ||||
|  | ||||
|     afterDone(rootMeta: NoteMeta): void { | ||||
|         this.#saveAssets(rootMeta, this.assetsMeta); | ||||
|         this.#saveIndex(rootMeta); | ||||
|     } | ||||
|  | ||||
|     mapExtension(type: string | null, mime: string, existingExtension: string, format: ExportFormat): string | null { | ||||
|         if (mime.startsWith("image/")) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return "html"; | ||||
|     } | ||||
|  | ||||
|     #saveIndex(rootMeta: NoteMeta) { | ||||
|         if (!this.indexMeta?.dataFileName) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const note = this.branch.getNote(); | ||||
|         const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch); | ||||
|         this.archive.append(fullHtml, { name: this.indexMeta.dataFileName }); | ||||
|     } | ||||
|  | ||||
|     #saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) { | ||||
|         for (const assetMeta of assetsMeta) { | ||||
|             if (!assetMeta.dataFileName) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             let cssContent = getShareThemeAssets(assetMeta.dataFileName); | ||||
|             this.archive.append(cssContent, { name: assetMeta.dataFileName }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| function getShareThemeAssets(nameWithExtension: string) { | ||||
|     let path: string | undefined; | ||||
|     if (nameWithExtension === "icon-color.svg") { | ||||
|         path = join(RESOURCE_DIR, "images", nameWithExtension); | ||||
|     } else if (nameWithExtension.startsWith("assets")) { | ||||
|         path = join(shareThemeAssetDir, nameWithExtension.replace(/^assets\//, "")); | ||||
|     } else if (isDev) { | ||||
|         path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension); | ||||
|     } else { | ||||
|         path = join(getResourceDir(), "public", "src", nameWithExtension); | ||||
|     } | ||||
|  | ||||
|     return fs.readFileSync(path); | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| import type { NoteType } from "@triliumnext/commons"; | ||||
| import type AttachmentMeta from "./attachment_meta.js"; | ||||
| import type AttributeMeta from "./attribute_meta.js"; | ||||
| import type { ExportFormat } from "../export/zip/abstract_provider.js"; | ||||
|  | ||||
| export interface NoteMetaFile { | ||||
|     formatVersion: number; | ||||
| @@ -19,7 +20,7 @@ export default interface NoteMeta { | ||||
|     type?: NoteType; | ||||
|     mime?: string; | ||||
|     /** 'html' or 'markdown', applicable to text notes only */ | ||||
|     format?: "html" | "markdown"; | ||||
|     format?: ExportFormat; | ||||
|     dataFileName?: string; | ||||
|     dirFileName?: string; | ||||
|     /** this file should not be imported (e.g., HTML navigation) */ | ||||
|   | ||||
| @@ -1,10 +1,23 @@ | ||||
| import { parse, HTMLElement, TextNode } from "node-html-parser"; | ||||
| import { parse, HTMLElement, TextNode, Options } from "node-html-parser"; | ||||
| import shaca from "./shaca/shaca.js"; | ||||
| import assetPath from "../services/asset_path.js"; | ||||
| import assetPath, { assetUrlFragment } from "../services/asset_path.js"; | ||||
| import shareRoot from "./share_root.js"; | ||||
| import escapeHtml from "escape-html"; | ||||
| import type SNote from "./shaca/entities/snote.js"; | ||||
| import BNote from "../becca/entities/bnote.js"; | ||||
| import type BBranch from "../becca/entities/bbranch.js"; | ||||
| import { t } from "i18next"; | ||||
| import SBranch from "./shaca/entities/sbranch.js"; | ||||
| import options from "../services/options.js"; | ||||
| import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js"; | ||||
| import ejs from "ejs"; | ||||
| import log from "../services/log.js"; | ||||
| import { join } from "path"; | ||||
| import { readFileSync } from "fs"; | ||||
| import { highlightAuto } from "@triliumnext/highlightjs"; | ||||
|  | ||||
| const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`; | ||||
| const templateCache: Map<string, string> = new Map(); | ||||
|  | ||||
| /** | ||||
|  * Represents the output of the content renderer. | ||||
| @@ -16,7 +29,192 @@ export interface Result { | ||||
|     isEmpty?: boolean; | ||||
| } | ||||
|  | ||||
| export function getContent(note: SNote) { | ||||
| interface Subroot { | ||||
|     note?: SNote | BNote; | ||||
|     branch?: SBranch | BBranch | ||||
| } | ||||
|  | ||||
| function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot { | ||||
|     if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) { | ||||
|         // share root itself is not shared | ||||
|         return {}; | ||||
|     } | ||||
|  | ||||
|     // every path leads to share root, but which one to choose? | ||||
|     // for the sake of simplicity, URLs are not note paths | ||||
|     const parentBranch = note.getParentBranches()[0]; | ||||
|  | ||||
|     if (note instanceof BNote) { | ||||
|         return { | ||||
|             note, | ||||
|             branch: parentBranch | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) { | ||||
|         return { | ||||
|             note, | ||||
|             branch: parentBranch | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     return getSharedSubTreeRoot(parentBranch.getParentNote()); | ||||
| } | ||||
|  | ||||
| export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[]) { | ||||
|     const subRoot: Subroot = { | ||||
|         branch: parentBranch, | ||||
|         note: parentBranch.getNote() | ||||
|     }; | ||||
|  | ||||
|     return renderNoteContentInternal(note, { | ||||
|         subRoot, | ||||
|         rootNoteId: parentBranch.noteId, | ||||
|         cssToLoad: [ | ||||
|             `${basePath}assets/styles.css`, | ||||
|             `${basePath}assets/scripts.css`, | ||||
|         ], | ||||
|         jsToLoad: [ | ||||
|             `${basePath}assets/scripts.js` | ||||
|         ], | ||||
|         logoUrl: `${basePath}icon-color.svg`, | ||||
|         ancestors | ||||
|     }); | ||||
| } | ||||
|  | ||||
| export function renderNoteContent(note: SNote) { | ||||
|     const subRoot = getSharedSubTreeRoot(note); | ||||
|  | ||||
|     const ancestors: string[] = []; | ||||
|     let notePointer = note; | ||||
|     while (notePointer.parents[0]?.noteId !== subRoot.note?.noteId) { | ||||
|         const pointerParent = notePointer.parents[0]; | ||||
|         if (!pointerParent) { | ||||
|             break; | ||||
|         } | ||||
|         ancestors.push(pointerParent.noteId); | ||||
|         notePointer = pointerParent; | ||||
|     } | ||||
|  | ||||
|     // Determine CSS to load. | ||||
|     const cssToLoad: string[] = []; | ||||
|     if (!note.isLabelTruthy("shareOmitDefaultCss")) { | ||||
|         cssToLoad.push(`assets/styles.css`); | ||||
|         cssToLoad.push(`assets/scripts.css`); | ||||
|     } | ||||
|     for (const cssRelation of note.getRelations("shareCss")) { | ||||
|         cssToLoad.push(`api/notes/${cssRelation.value}/download`); | ||||
|     } | ||||
|  | ||||
|     // Determine JS to load. | ||||
|     const jsToLoad: string[] = [ | ||||
|         "assets/scripts.js" | ||||
|     ]; | ||||
|     for (const jsRelation of note.getRelations("shareJs")) { | ||||
|         jsToLoad.push(`api/notes/${jsRelation.value}/download`); | ||||
|     } | ||||
|  | ||||
|     const customLogoId = note.getRelation("shareLogo")?.value; | ||||
|     const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`; | ||||
|  | ||||
|     return renderNoteContentInternal(note, { | ||||
|         subRoot, | ||||
|         rootNoteId: "_share", | ||||
|         cssToLoad, | ||||
|         jsToLoad, | ||||
|         logoUrl, | ||||
|         ancestors | ||||
|     }); | ||||
| } | ||||
|  | ||||
| interface RenderArgs { | ||||
|     subRoot: Subroot; | ||||
|     rootNoteId: string; | ||||
|     cssToLoad: string[]; | ||||
|     jsToLoad: string[]; | ||||
|     logoUrl: string; | ||||
|     ancestors: string[]; | ||||
| } | ||||
|  | ||||
| function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) { | ||||
|     const { header, content, isEmpty } = getContent(note); | ||||
|     const showLoginInShareTheme = options.getOption("showLoginInShareTheme"); | ||||
|     const opts = { | ||||
|         note, | ||||
|         header, | ||||
|         content, | ||||
|         isEmpty, | ||||
|         assetPath: shareAdjustedAssetPath, | ||||
|         assetUrlFragment, | ||||
|         showLoginInShareTheme, | ||||
|         t, | ||||
|         isDev, | ||||
|         utils, | ||||
|         ...renderArgs | ||||
|     }; | ||||
|  | ||||
|     // Check if the user has their own template. | ||||
|     if (note.hasRelation("shareTemplate")) { | ||||
|         // Get the template note and content | ||||
|         const templateId = note.getRelation("shareTemplate")?.value; | ||||
|         const templateNote = templateId && shaca.getNote(templateId); | ||||
|  | ||||
|         // Make sure the note type is correct | ||||
|         if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") { | ||||
|             // EJS caches the result of this so we don't need to pre-cache | ||||
|             const includer = (path: string) => { | ||||
|                 const childNote = templateNote.children.find((n) => path === n.title); | ||||
|                 if (!childNote) throw new Error(`Unable to find child note: ${path}.`); | ||||
|                 if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type."); | ||||
|  | ||||
|                 const template = childNote.getContent(); | ||||
|                 if (typeof template !== "string") throw new Error("Invalid template content type."); | ||||
|  | ||||
|                 return { template }; | ||||
|             }; | ||||
|  | ||||
|             // Try to render user's template, w/ fallback to default view | ||||
|             try { | ||||
|                 const content = templateNote.getContent(); | ||||
|                 if (typeof content === "string") { | ||||
|                     return ejs.render(content, opts, { includer }); | ||||
|                 } | ||||
|             } catch (e: unknown) { | ||||
|                 const [errMessage, errStack] = safeExtractMessageAndStackFromError(e); | ||||
|                 log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Render with the default view otherwise. | ||||
|     const templatePath = getDefaultTemplatePath("page"); | ||||
|     return ejs.render(readTemplate(templatePath), opts, { | ||||
|         includer: (path) => { | ||||
|             // Path is relative to apps/server/dist/assets/views | ||||
|             return { template: readTemplate(getDefaultTemplatePath(path)) }; | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| function getDefaultTemplatePath(template: string) { | ||||
|     // Path is relative to apps/server/dist/assets/views | ||||
|     return process.env.NODE_ENV === "development" | ||||
|         ? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`) | ||||
|         : join(getResourceDir(), `share-theme/templates/${template}.ejs`); | ||||
| } | ||||
|  | ||||
| function readTemplate(path: string) { | ||||
|     const cachedTemplate = templateCache.get(path); | ||||
|     if (cachedTemplate) { | ||||
|         return cachedTemplate; | ||||
|     } | ||||
|  | ||||
|     const templateString = readFileSync(path, "utf-8"); | ||||
|     templateCache.set(path, templateString); | ||||
|     return templateString; | ||||
| } | ||||
|  | ||||
| export function getContent(note: SNote | BNote) { | ||||
|     if (note.isProtected) { | ||||
|         return { | ||||
|             header: "", | ||||
| @@ -65,9 +263,12 @@ function renderIndex(result: Result) { | ||||
|     result.content += "</ul>"; | ||||
| } | ||||
|  | ||||
| function renderText(result: Result, note: SNote) { | ||||
| function renderText(result: Result, note: SNote | BNote) { | ||||
|     if (typeof result.content !== "string") return; | ||||
|     const document = parse(result.content || ""); | ||||
|     const parseOpts: Partial<Options> = { | ||||
|         blockTextElements: {} | ||||
|     } | ||||
|     const document = parse(result.content || "", parseOpts); | ||||
|  | ||||
|     // Process include notes. | ||||
|     for (const includeNoteEl of document.querySelectorAll("section.include-note")) { | ||||
| @@ -80,7 +281,7 @@ function renderText(result: Result, note: SNote) { | ||||
|         const includedResult = getContent(note); | ||||
|         if (typeof includedResult.content !== "string") continue; | ||||
|  | ||||
|         const includedDocument = parse(includedResult.content).childNodes; | ||||
|         const includedDocument = parse(includedResult.content, parseOpts).childNodes; | ||||
|         if (includedDocument) { | ||||
|             includeNoteEl.replaceWith(...includedDocument); | ||||
|         } | ||||
| @@ -89,6 +290,7 @@ function renderText(result: Result, note: SNote) { | ||||
|     result.isEmpty = document.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0; | ||||
|  | ||||
|     if (!result.isEmpty) { | ||||
|         // Process attachment links. | ||||
|         for (const linkEl of document.querySelectorAll("a")) { | ||||
|             const href = linkEl.getAttribute("href"); | ||||
|  | ||||
| @@ -102,21 +304,15 @@ function renderText(result: Result, note: SNote) { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         result.content = document.innerHTML ?? ""; | ||||
|  | ||||
|         if (result.content.includes(`<span class="math-tex">`)) { | ||||
|             result.header += ` | ||||
| <script src="../${assetPath}/node_modules/katex/dist/katex.min.js"></script> | ||||
| <link rel="stylesheet" href="../${assetPath}/node_modules/katex/dist/katex.min.css"> | ||||
| <script src="../${assetPath}/node_modules/katex/dist/contrib/auto-render.min.js"></script> | ||||
| <script src="../${assetPath}/node_modules/katex/dist/contrib/mhchem.min.js"></script> | ||||
| <script> | ||||
| document.addEventListener("DOMContentLoaded", function() { | ||||
|     renderMathInElement(document.getElementById('content')); | ||||
| }); | ||||
| </script>`; | ||||
|         // Apply syntax highlight. | ||||
|         for (const codeEl of document.querySelectorAll("pre code")) { | ||||
|             const highlightResult = highlightAuto(codeEl.innerText); | ||||
|             codeEl.innerHTML = highlightResult.value; | ||||
|             codeEl.classList.add("hljs"); | ||||
|         } | ||||
|  | ||||
|         result.content = document.innerHTML ?? ""; | ||||
|  | ||||
|         if (note.hasLabel("shareIndex")) { | ||||
|             renderIndex(result); | ||||
|         } | ||||
| @@ -174,7 +370,7 @@ export function renderCode(result: Result) { | ||||
|     } | ||||
| } | ||||
|  | ||||
| function renderMermaid(result: Result, note: SNote) { | ||||
| function renderMermaid(result: Result, note: SNote | BNote) { | ||||
|     if (typeof result.content !== "string") { | ||||
|         return; | ||||
|     } | ||||
| @@ -188,11 +384,11 @@ function renderMermaid(result: Result, note: SNote) { | ||||
| </details>`; | ||||
| } | ||||
|  | ||||
| function renderImage(result: Result, note: SNote) { | ||||
| function renderImage(result: Result, note: SNote | BNote) { | ||||
|     result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">`; | ||||
| } | ||||
|  | ||||
| function renderFile(note: SNote, result: Result) { | ||||
| function renderFile(note: SNote | BNote, result: Result) { | ||||
|     if (note.mime === "application/pdf") { | ||||
|         result.content = `<iframe class="pdf-view" src="api/notes/${note.noteId}/view"></iframe>`; | ||||
|     } else { | ||||
|   | ||||
| @@ -4,41 +4,12 @@ import type { Request, Response, Router } from "express"; | ||||
|  | ||||
| import shaca from "./shaca/shaca.js"; | ||||
| import shacaLoader from "./shaca/shaca_loader.js"; | ||||
| import shareRoot from "./share_root.js"; | ||||
| import contentRenderer from "./content_renderer.js"; | ||||
| import assetPath, { assetUrlFragment } from "../services/asset_path.js"; | ||||
| import appPath from "../services/app_path.js"; | ||||
| import searchService from "../services/search/services/search.js"; | ||||
| import SearchContext from "../services/search/search_context.js"; | ||||
| import log from "../services/log.js"; | ||||
| import type SNote from "./shaca/entities/snote.js"; | ||||
| import type SBranch from "./shaca/entities/sbranch.js"; | ||||
| import type SAttachment from "./shaca/entities/sattachment.js"; | ||||
| import utils, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js"; | ||||
| import options from "../services/options.js"; | ||||
| import { t } from "i18next"; | ||||
| import ejs from "ejs"; | ||||
| import { join } from "path"; | ||||
|  | ||||
| function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } { | ||||
|     if (note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) { | ||||
|         // share root itself is not shared | ||||
|         return {}; | ||||
|     } | ||||
|  | ||||
|     // every path leads to share root, but which one to choose? | ||||
|     // for the sake of simplicity, URLs are not note paths | ||||
|     const parentBranch = note.getParentBranches()[0]; | ||||
|  | ||||
|     if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) { | ||||
|         return { | ||||
|             note, | ||||
|             branch: parentBranch | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     return getSharedSubTreeRoot(parentBranch.getParentNote()); | ||||
| } | ||||
| import { renderNoteContent } from "./content_renderer.js"; | ||||
| import utils from "../services/utils.js"; | ||||
|  | ||||
| function addNoIndexHeader(note: SNote, res: Response) { | ||||
|     if (note.isLabelTruthy("shareDisallowRobotIndexing")) { | ||||
| @@ -109,8 +80,7 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri | ||||
|     let svgString = "<svg/>"; | ||||
|     const attachment = image.getAttachmentByTitle(attachmentName); | ||||
|     if (!attachment) { | ||||
|         res.status(404); | ||||
|         renderDefault(res, "404"); | ||||
|  | ||||
|         return; | ||||
|     } | ||||
|     const content = attachment.getContent(); | ||||
| @@ -138,12 +108,19 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri | ||||
|     res.send(svg); | ||||
| } | ||||
|  | ||||
| function render404(res: Response) { | ||||
|     res.status(404); | ||||
|     const shareThemePath = `../../share-theme/templates/404.ejs`; | ||||
|     res.render(shareThemePath); | ||||
| } | ||||
|  | ||||
| function register(router: Router) { | ||||
|  | ||||
|     function renderNote(note: SNote, req: Request, res: Response) { | ||||
|         if (!note) { | ||||
|             console.log("Unable to find note ", note); | ||||
|             res.status(404); | ||||
|             renderDefault(res, "404"); | ||||
|             render404(res); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -161,63 +138,7 @@ function register(router: Router) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const { header, content, isEmpty } = contentRenderer.getContent(note); | ||||
|         const subRoot = getSharedSubTreeRoot(note); | ||||
|         const showLoginInShareTheme = options.getOption("showLoginInShareTheme"); | ||||
|         const opts = { | ||||
|             note, | ||||
|             header, | ||||
|             content, | ||||
|             isEmpty, | ||||
|             subRoot, | ||||
|             assetPath: isDev ? assetPath : `../${assetPath}`, | ||||
|             assetUrlFragment, | ||||
|             appPath: isDev ? appPath : `../${appPath}`, | ||||
|             showLoginInShareTheme, | ||||
|             t, | ||||
|             isDev, | ||||
|             utils | ||||
|         }; | ||||
|         let useDefaultView = true; | ||||
|  | ||||
|         // Check if the user has their own template | ||||
|         if (note.hasRelation("shareTemplate")) { | ||||
|             // Get the template note and content | ||||
|             const templateId = note.getRelation("shareTemplate")?.value; | ||||
|             const templateNote = templateId && shaca.getNote(templateId); | ||||
|  | ||||
|             // Make sure the note type is correct | ||||
|             if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") { | ||||
|                 // EJS caches the result of this so we don't need to pre-cache | ||||
|                 const includer = (path: string) => { | ||||
|                     const childNote = templateNote.children.find((n) => path === n.title); | ||||
|                     if (!childNote) throw new Error(`Unable to find child note: ${path}.`); | ||||
|                     if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type."); | ||||
|  | ||||
|                     const template = childNote.getContent(); | ||||
|                     if (typeof template !== "string") throw new Error("Invalid template content type."); | ||||
|  | ||||
|                     return { template }; | ||||
|                 }; | ||||
|  | ||||
|                 // Try to render user's template, w/ fallback to default view | ||||
|                 try { | ||||
|                     const content = templateNote.getContent(); | ||||
|                     if (typeof content === "string") { | ||||
|                         const ejsResult = ejs.render(content, opts, { includer }); | ||||
|                         res.send(ejsResult); | ||||
|                         useDefaultView = false; // Rendering went okay, don't use default view | ||||
|                     } | ||||
|                 } catch (e: unknown) { | ||||
|                     const [errMessage, errStack] = safeExtractMessageAndStackFromError(e); | ||||
|                     log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (useDefaultView) { | ||||
|             renderDefault(res, "page", opts); | ||||
|         } | ||||
|         res.send(renderNoteContent(note)); | ||||
|     } | ||||
|  | ||||
|     router.get("/share/", (req, res) => { | ||||
| @@ -401,14 +322,6 @@ function register(router: Router) { | ||||
|     }); | ||||
| } | ||||
|  | ||||
| function renderDefault(res: Response<any, Record<string, any>>, template: "page" | "404", opts: any = {}) { | ||||
|     // Path is relative to apps/server/dist/assets/views | ||||
|     const shareThemePath = process.env.NODE_ENV === "development" | ||||
|         ? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`) | ||||
|         : `../../share-theme/templates/${template}.ejs`; | ||||
|     res.render(shareThemePath, opts); | ||||
| } | ||||
|  | ||||
| export default { | ||||
|     register | ||||
| }; | ||||
|   | ||||
							
								
								
									
										6
									
								
								apps/server/src/types.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								apps/server/src/types.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -38,3 +38,9 @@ declare module "@triliumnext/share-theme/styles.css" { | ||||
|     const content: string; | ||||
|     export default content; | ||||
| } | ||||
|  | ||||
| declare module '*.css' {} | ||||
| declare module '*?raw' { | ||||
|   const src: string | ||||
|   export default src | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| <html> | ||||
| 	<head> | ||||
| 		<meta charset="UTF-8" /> | ||||
| 		<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.ico" /> | ||||
|   | ||||
| @@ -14,7 +14,7 @@ | ||||
| 		"preact": "10.27.2", | ||||
| 		"preact-iso": "2.11.0", | ||||
| 		"preact-render-to-string": "6.6.3", | ||||
| 		"react-i18next": "16.2.0" | ||||
| 		"react-i18next": "16.2.1" | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
| 		"@preact/preset-vite": "2.10.2", | ||||
|   | ||||
| @@ -19,6 +19,7 @@ describe("swapLocale", () => { | ||||
|         expect(swapLocaleInUrl("/ro/get-started", "ro")).toStrictEqual("/ro/get-started"); | ||||
|         expect(swapLocaleInUrl("/en/get-started", "ro")).toStrictEqual("/ro/get-started"); | ||||
|         expect(swapLocaleInUrl("/ro/", "en")).toStrictEqual("/en/"); | ||||
|         expect(swapLocaleInUrl("/ro", "en")).toStrictEqual("/en"); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,36 @@ | ||||
| import i18next from "i18next"; | ||||
| import { initReactI18next } from "react-i18next"; | ||||
|  | ||||
| interface Locale { | ||||
|     id: string; | ||||
|     name: string; | ||||
|     rtl?: boolean; | ||||
| } | ||||
|  | ||||
| i18next.use(initReactI18next); | ||||
| const localeFiles = import.meta.glob("./translations/*/translation.json", { eager: true }); | ||||
| const resources: Record<string, Record<string, Record<string, string>>> = {}; | ||||
| for (const [path, module] of Object.entries(localeFiles)) { | ||||
|     const id = path.split("/").at(-2); | ||||
|     if (!id) continue; | ||||
|  | ||||
|     const translations = (module as any).default ?? module; | ||||
|     resources[id] = { translation: translations }; | ||||
| } | ||||
|  | ||||
| export function initTranslations(lng: string) { | ||||
|     i18next.init({ | ||||
|         fallbackLng: "en", | ||||
|         lng, | ||||
|         returnEmptyString: false, | ||||
|         resources, | ||||
|         initAsync: false, | ||||
|         react: { | ||||
|             useSuspense: false | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| export const LOCALES: Locale[] = [ | ||||
|     { id: "en", name: "English" }, | ||||
|     { id: "ro", name: "Română" }, | ||||
| @@ -35,7 +62,13 @@ export function mapLocale(locale: string) { | ||||
| export function swapLocaleInUrl(url: string, newLocale: string) { | ||||
|     const components = url.split("/"); | ||||
|     if (components.length === 2) { | ||||
|         return `/${newLocale}${url}`; | ||||
|         const potentialLocale = components[1]; | ||||
|         const correspondingLocale = LOCALES.find(l => l.id === potentialLocale); | ||||
|         if (correspondingLocale) { | ||||
|             return `/${newLocale}`; | ||||
|         } else { | ||||
|             return `/${newLocale}${url}`; | ||||
|         } | ||||
|     } else { | ||||
|         components[1] = newLocale; | ||||
|         return components.join("/"); | ||||
|   | ||||
| @@ -8,11 +8,9 @@ import Footer from './components/Footer.js'; | ||||
| import GetStarted from './pages/GetStarted/get-started.js'; | ||||
| import SupportUs from './pages/SupportUs/SupportUs.js'; | ||||
| import { createContext } from 'preact'; | ||||
| import { useLayoutEffect, useState } from 'preact/hooks'; | ||||
| import { default as i18next, changeLanguage } from 'i18next'; | ||||
| import { extractLocaleFromUrl, LOCALES, mapLocale } from './i18n'; | ||||
| import HttpApi from 'i18next-http-backend'; | ||||
| import { initReactI18next } from "react-i18next"; | ||||
| import { useLayoutEffect, useRef } from 'preact/hooks'; | ||||
| import { changeLanguage } from 'i18next'; | ||||
| import { extractLocaleFromUrl, initTranslations, LOCALES, mapLocale } from './i18n'; | ||||
|  | ||||
| export const LocaleContext = createContext('en'); | ||||
|  | ||||
| @@ -42,34 +40,26 @@ export function App(props: {repoStargazersCount: number}) { | ||||
|  | ||||
| export function LocaleProvider({ children }) { | ||||
|   const { path } = useLocation(); | ||||
|   const localeId = mapLocale(extractLocaleFromUrl(path) || navigator.language); | ||||
|   const [ loaded, setLoaded ] = useState(false); | ||||
|   const localeId = getLocaleId(path); | ||||
|   const loadedRef = useRef(false); | ||||
|  | ||||
|   useLayoutEffect(() => { | ||||
|       i18next | ||||
|         .use(HttpApi) | ||||
|         .use(initReactI18next); | ||||
|       i18next.init({ | ||||
|         lng: localeId, | ||||
|         fallbackLng: "en", | ||||
|         backend: { | ||||
|             loadPath: "/translations/{{lng}}/{{ns}}.json", | ||||
|         }, | ||||
|         returnEmptyString: false | ||||
|     }).then(() => setLoaded(true)) | ||||
| }, []); | ||||
|  | ||||
|   useLayoutEffect(() => { | ||||
|     if (!loaded) return; | ||||
|   if (!loadedRef.current) { | ||||
|     initTranslations(localeId); | ||||
|     loadedRef.current = true; | ||||
|   } else { | ||||
|     changeLanguage(localeId); | ||||
|   } | ||||
|  | ||||
|   // Update html lang and dir attributes | ||||
|   useLayoutEffect(() => { | ||||
|     const correspondingLocale = LOCALES.find(l => l.id === localeId); | ||||
|     document.documentElement.lang = localeId; | ||||
|     document.documentElement.dir = correspondingLocale?.rtl ? "rtl" : "ltr"; | ||||
|   }, [ loaded, localeId ]); | ||||
|   }, [localeId]); | ||||
|  | ||||
|   return ( | ||||
|     <LocaleContext.Provider value={localeId}> | ||||
|       {loaded && children} | ||||
|       {children} | ||||
|     </LocaleContext.Provider> | ||||
|   ); | ||||
| } | ||||
| @@ -78,12 +68,26 @@ if (typeof window !== 'undefined') { | ||||
| 	hydrate(<App repoStargazersCount={FALLBACK_STARGAZERS_COUNT} />, document.getElementById('app')!); | ||||
| } | ||||
|  | ||||
| function getLocaleId(path: string) { | ||||
|     const extractedLocale = extractLocaleFromUrl(path); | ||||
|     if (extractedLocale) return mapLocale(extractedLocale); | ||||
|     if (typeof window === "undefined") return 'en'; | ||||
|     return mapLocale(navigator.language); | ||||
| } | ||||
|  | ||||
| export async function prerender(data) { | ||||
| 	// Fetch the stargazer count of the Trilium's GitHub repo on prerender to pass | ||||
| 	// it to the App component for SSR. | ||||
| 	// This ensures the GitHub API is not called on every page load in the client. | ||||
| 	const stargazersCount = await getRepoStargazersCount(); | ||||
|  | ||||
| 	return await ssr(<App repoStargazersCount={stargazersCount} {...data} />); | ||||
| 	const { html, links } = await ssr(<App repoStargazersCount={stargazersCount} {...data} />); | ||||
|     return { | ||||
|         html, | ||||
|         links, | ||||
|         head: { | ||||
|             lang: extractLocaleFromUrl(data.url) ?? "en" | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -95,8 +95,7 @@ | ||||
|         "get_started": "Comenzar" | ||||
|     }, | ||||
|     "components": { | ||||
|         "link_learn_more": "Saber más…", | ||||
|         "list_with_screenshot_alt": "Captura de pantalla de la función seleccionada" | ||||
|         "link_learn_more": "Saber más…" | ||||
|     }, | ||||
|     "download_now": { | ||||
|         "text": "Descarga ahora ", | ||||
| @@ -74,8 +74,7 @@ | ||||
|         "get_started": "Commencer" | ||||
|     }, | ||||
|     "components": { | ||||
|         "link_learn_more": "En savoir plus...", | ||||
|         "list_with_screenshot_alt": "Capture d'écran de la fonctionnalité sélectionnée" | ||||
|         "link_learn_more": "En savoir plus..." | ||||
|     }, | ||||
|     "support_us": { | ||||
|         "financial_donations_title": "Dons financiers", | ||||
							
								
								
									
										5
									
								
								apps/website/src/translations/hi/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								apps/website/src/translations/hi/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| { | ||||
|     "get-started": { | ||||
|         "title": "शुरू करें" | ||||
|     } | ||||
| } | ||||
| @@ -51,7 +51,8 @@ | ||||
|         "mermaid_description": "Crea diagrammi come diagrammi di flusso, diagrammi di classe e sequenza, diagrammi di Gantt e molti altri, utilizzando la sintassi Mermaid.", | ||||
|         "mindmap_title": "Mappe mentali", | ||||
|         "mindmap_description": "Organizza i tuoi pensieri visivamente o fai una sessione di brainstorming.", | ||||
|         "others_list": "e altri: <0>mappa delle note</0>, <1>mappa delle relazioni</1>, <2>ricerche salvate</2>, <3>renderizza nota</3> e <4>visualizzazioni web</4>." | ||||
|         "others_list": "e altri: <0>mappa delle note</0>, <1>mappa delle relazioni</1>, <2>ricerche salvate</2>, <3>renderizza nota</3> e <4>visualizzazioni web</4>.", | ||||
|         "title": "Diversi modi per rappresentare le tue informazioni" | ||||
|     }, | ||||
|     "extensibility_benefits": { | ||||
|         "title": "Condivisione ed estensibilità", | ||||
| @@ -72,7 +73,10 @@ | ||||
|         "board_title": "Board", | ||||
|         "board_description": "Organizza le tue attività o lo stato dei tuoi progetti in una lavagna Kanban con un modo semplice per creare nuovi elementi e colonne e modificare facilmente il loro stato trascinandoli sulla lavagna.", | ||||
|         "geomap_title": "Geomappa", | ||||
|         "geomap_description": "Pianifica le tue vacanze o segna i tuoi punti di interesse direttamente su una mappa geografica utilizzando indicatori personalizzabili. Visualizza le tracce GPX registrate per seguire gli itinerari." | ||||
|         "geomap_description": "Pianifica le tue vacanze o segna i tuoi punti di interesse direttamente su una mappa geografica utilizzando indicatori personalizzabili. Visualizza le tracce GPX registrate per seguire gli itinerari.", | ||||
|         "title": "Collezioni", | ||||
|         "presentation_title": "Presentazione", | ||||
|         "presentation_description": "Organizza le informazioni in diapositive e presentale a schermo intero con transizioni fluide. Le diapositive possono anche essere esportate in formato PDF per una facile condivisione." | ||||
|     }, | ||||
|     "faq": { | ||||
|         "title": "Domande frequenti", | ||||
| @@ -95,8 +99,7 @@ | ||||
|         "get_started": "Inizia ora" | ||||
|     }, | ||||
|     "components": { | ||||
|         "link_learn_more": "Per saperne di più...", | ||||
|         "list_with_screenshot_alt": "Screenshot della funzione selezionata" | ||||
|         "link_learn_more": "Per saperne di più..." | ||||
|     }, | ||||
|     "download_now": { | ||||
|         "text": "Scarica ora ", | ||||
| @@ -188,5 +191,10 @@ | ||||
|         "description": "Trilium Notes è ospitato su PikaPods, un servizio a pagamento che consente un facile accesso e una semplice gestione. Non è direttamente affiliato al team Trilium.", | ||||
|         "download_pikapod": "Configurazione su PikaPods", | ||||
|         "download_triliumcc": "In alternativa, consultare trilium.cc" | ||||
|     }, | ||||
|     "header": { | ||||
|         "get-started": "Inizia", | ||||
|         "documentation": "Documentazione", | ||||
|         "support-us": "Sostienici" | ||||
|     } | ||||
| } | ||||
| @@ -51,7 +51,8 @@ | ||||
|         "mermaid_description": "Mermaid 構文を使用して、フローチャート、クラス図、シーケンス図、ガント チャートなどの図を作成します。", | ||||
|         "mindmap_title": "マインドマップ", | ||||
|         "mindmap_description": "考えを視覚的に整理したり、ブレインストーミング セッションを行ったりします。", | ||||
|         "others_list": "その他: <0>ノートマップ</0>、<1>リレーションマップ</1>、<2>保存された検索</2>、<3>レンダリングノート</3>、<4>Web ビュー</4>。" | ||||
|         "others_list": "その他: <0>ノートマップ</0>、<1>リレーションマップ</1>、<2>保存された検索</2>、<3>レンダリングノート</3>、<4>Web ビュー</4>。", | ||||
|         "title": "情報を表現するための複数の方法" | ||||
|     }, | ||||
|     "extensibility_benefits": { | ||||
|         "title": "共有と拡張性", | ||||
| @@ -72,7 +73,10 @@ | ||||
|         "board_title": "ボード", | ||||
|         "board_description": "新しい項目や列を簡単に作成し、ボード上でドラッグするだけでステータスを変更できるカンバン ボードで、タスクやプロジェクトのステータスを整理できます。", | ||||
|         "geomap_title": "ジオマップ", | ||||
|         "geomap_description": "カスタマイズ可能なマーカーを使って、休暇を計画したり、興味のある場所を地図上に直接マークしたりできます。記録されたGPXトラックを表示して、旅程を追跡できます。" | ||||
|         "geomap_description": "カスタマイズ可能なマーカーを使って、休暇を計画したり、興味のある場所を地図上に直接マークしたりできます。記録されたGPXトラックを表示して、旅程を追跡できます。", | ||||
|         "title": "コレクション", | ||||
|         "presentation_title": "プレゼンテーション", | ||||
|         "presentation_description": "情報をスライドに整理し、スムーズな遷移で全画面表示できます。スライドはPDFにエクスポートできるので、簡単に共有できます。" | ||||
|     }, | ||||
|     "faq": { | ||||
|         "title": "よくある質問", | ||||
| @@ -95,8 +99,7 @@ | ||||
|         "get_started": "はじめる" | ||||
|     }, | ||||
|     "components": { | ||||
|         "link_learn_more": "さらに詳しく...", | ||||
|         "list_with_screenshot_alt": "選択中の機能のスクリーンショット" | ||||
|         "link_learn_more": "さらに詳しく..." | ||||
|     }, | ||||
|     "download_now": { | ||||
|         "text": "今すぐダウンロード ", | ||||
| @@ -188,5 +191,10 @@ | ||||
|         "description": "Trilium Notesは、アクセスと管理を容易にする有料サービス PikaPods でホストされています。Trilium チームとは直接関係ありません。", | ||||
|         "download_pikapod": "PikaPods にセットアップする", | ||||
|         "download_triliumcc": "または、trilium.cc を参照してください" | ||||
|     }, | ||||
|     "header": { | ||||
|         "get-started": "はじめる", | ||||
|         "documentation": "ドキュメント", | ||||
|         "support-us": "サポート" | ||||
|     } | ||||
| } | ||||
							
								
								
									
										6
									
								
								apps/website/src/translations/ko/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								apps/website/src/translations/ko/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| { | ||||
|     "hero_section": { | ||||
|         "github": "깃허브", | ||||
|         "dockerhub": "도커 허브" | ||||
|     } | ||||
| } | ||||
| @@ -95,8 +95,7 @@ | ||||
|         "get_started": "Start" | ||||
|     }, | ||||
|     "components": { | ||||
|         "link_learn_more": "Dowiedz się więcej....", | ||||
|         "list_with_screenshot_alt": "Zrzut ekranu wybranej funkcji" | ||||
|         "link_learn_more": "Dowiedz się więcej...." | ||||
|     }, | ||||
|     "download_now": { | ||||
|         "text": "Pobierz teraz ", | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user