From 2c6ba9ba2cbc8b7eff96560f25919f8ffa64010e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 13 Jun 2025 17:42:11 +0300 Subject: [PATCH 001/360] refactor(share): extract note rendering logic --- apps/server/src/share/content_renderer.ts | 91 +++++++++++++++++- apps/server/src/share/routes.ts | 108 +++------------------- 2 files changed, 102 insertions(+), 97 deletions(-) diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index c1c16e48d0..e94eb42979 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -1,10 +1,18 @@ import { JSDOM } from "jsdom"; 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 { t } from "i18next"; +import SBranch from "./shaca/entities/sbranch.js"; +import options from "../services/options.js"; +import { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js"; +import app_path from "../services/app_path.js"; +import ejs from "ejs"; +import log from "../services/log.js"; +import { join } from "path"; +import { readFileSync } from "fs"; /** * Represents the output of the content renderer. @@ -16,6 +24,87 @@ export interface Result { isEmpty?: boolean; } +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()); +} + +export function renderNoteContent(note: SNote) { + const { header, content, isEmpty } = 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 ? app_path : `../${app_path}`, + showLoginInShareTheme, + t, + isDev + }; + + // 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 = join(getResourceDir(), "share-theme", "templates", "page.ejs"); + return ejs.render(readFileSync(templatePath, "utf-8"), opts, { + includer: (path) => { + const templatePath = join(getResourceDir(), "share-theme", "templates", `${path}.ejs`); + return { filename: templatePath } + } + }); +} + function getContent(note: SNote) { if (note.isProtected) { return { diff --git a/apps/server/src/share/routes.ts b/apps/server/src/share/routes.ts index 7e18ca505b..4b0281ec53 100644 --- a/apps/server/src/share/routes.ts +++ b/apps/server/src/share/routes.ts @@ -4,40 +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"; - -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 utils from "../services/utils.js"; +import { renderNoteContent } from "./content_renderer.js"; function addNoIndexHeader(note: SNote, res: Response) { if (note.isLabelTruthy("shareDisallowRobotIndexing")) { @@ -108,8 +80,7 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri let svgString = ""; const attachment = image.getAttachmentByTitle(attachmentName); if (!attachment) { - res.status(404); - renderDefault(res, "404"); + return; } const content = attachment.getContent(); @@ -137,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; } @@ -159,63 +137,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 - }; - 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) => { @@ -399,12 +321,6 @@ function register(router: Router) { }); } -function renderDefault(res: Response>, template: "page" | "404", opts: any = {}) { - // Path is relative to apps/server/dist/assets/views - const shareThemePath = `../../share-theme/templates/${template}.ejs`; - res.render(shareThemePath, opts); -} - export default { register }; From 9c460dbc87791a3ecbe36d54b3784f261ef40926 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 13 Jun 2025 23:10:11 +0300 Subject: [PATCH 002/360] feat(export/zip): get same rendering engine as share --- apps/server/src/becca/entities/bbranch.ts | 5 +++++ apps/server/src/becca/entities/bnote.ts | 16 +++++++++++++++ apps/server/src/services/export/zip.ts | 16 ++++++++++----- apps/server/src/share/content_renderer.ts | 25 +++++++++++++++-------- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/apps/server/src/becca/entities/bbranch.ts b/apps/server/src/becca/entities/bbranch.ts index 00e3ec4b75..b31cadd71c 100644 --- a/apps/server/src/becca/entities/bbranch.ts +++ b/apps/server/src/becca/entities/bbranch.ts @@ -278,6 +278,11 @@ class BBranch extends AbstractBeccaEntity { }); } } + + getParentNote() { + return this.parentNote; + } + } export default BBranch; diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts index 419c9bdfe4..6503167778 100644 --- a/apps/server/src/becca/entities/bnote.ts +++ b/apps/server/src/becca/entities/bnote.ts @@ -1758,6 +1758,22 @@ class BNote extends AbstractBeccaEntity { 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; + } + } export default BNote; diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 81c67a21b0..f544f2a73d 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -19,9 +19,11 @@ 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 type BNote from "../../becca/entities/bnote.js"; import type { Response } from "express"; import type { NoteMetaFile } from "../meta/note_meta.js"; import cssContent from "@triliumnext/ckeditor5/content.css"; +import { renderNoteContent } from "../../share/content_renderer.js"; type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; @@ -314,7 +316,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h } } - function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { + function prepareContent(note: BNote | undefined, title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { if (["html", "markdown"].includes(noteMeta?.format || "")) { content = content.toString(); content = rewriteFn(content, noteMeta); @@ -329,8 +331,11 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`; const htmlTitle = escapeHtml(title); - // element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 - content = ` + if (note) { + content = renderNoteContent(note); + } else { + // element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 + content = ` @@ -346,6 +351,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h `; + } } return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content; @@ -375,7 +381,7 @@ ${markdownContent}`; let content: string | Buffer = `

This is a clone of a note. Go to its primary location.

`; - content = prepareContent(noteMeta.title, content, noteMeta); + content = prepareContent(undefined, noteMeta.title, content, noteMeta); archive.append(content, { name: filePathPrefix + noteMeta.dataFileName }); @@ -391,7 +397,7 @@ ${markdownContent}`; } if (noteMeta.dataFileName) { - const content = prepareContent(noteMeta.title, note.getContent(), noteMeta); + const content = prepareContent(note, noteMeta.title, note.getContent(), noteMeta); archive.append(content, { name: filePathPrefix + noteMeta.dataFileName, diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index e94eb42979..aad3ab9d2d 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -4,6 +4,8 @@ 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"; @@ -24,8 +26,8 @@ export interface Result { isEmpty?: boolean; } -function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } { - if (note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) { +function getSharedSubTreeRoot(note: SNote | BNote | undefined): { note?: SNote | BNote; branch?: SBranch | BBranch } { + if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) { // share root itself is not shared return {}; } @@ -34,6 +36,13 @@ function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } { // 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, @@ -44,7 +53,7 @@ function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } { return getSharedSubTreeRoot(parentBranch.getParentNote()); } -export function renderNoteContent(note: SNote) { +export function renderNoteContent(note: SNote | BNote) { const { header, content, isEmpty } = getContent(note); const subRoot = getSharedSubTreeRoot(note); const showLoginInShareTheme = options.getOption("showLoginInShareTheme"); @@ -105,7 +114,7 @@ export function renderNoteContent(note: SNote) { }); } -function getContent(note: SNote) { +function getContent(note: SNote | BNote) { if (note.isProtected) { return { header: "", @@ -154,7 +163,7 @@ function renderIndex(result: Result) { result.content += ""; } -function renderText(result: Result, note: SNote) { +function renderText(result: Result, note: SNote | BNote) { const document = new JSDOM(result.content || "").window.document; result.isEmpty = document.body.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0; @@ -247,7 +256,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; } @@ -261,11 +270,11 @@ function renderMermaid(result: Result, note: SNote) { `; } -function renderImage(result: Result, note: SNote) { +function renderImage(result: Result, note: SNote | BNote) { result.content = ``; } -function renderFile(note: SNote, result: Result) { +function renderFile(note: SNote | BNote, result: Result) { if (note.mime === "application/pdf") { result.content = ``; } else { From f189deb415e4ee2b67d9a9480c56634ede1c5d20 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 13 Jun 2025 23:22:44 +0300 Subject: [PATCH 003/360] feat(export/zip): get tree to render --- apps/server/src/services/export/zip.ts | 4 ++-- apps/server/src/share/content_renderer.ts | 26 +++++++++++++++++---- packages/share-theme/src/templates/page.ejs | 2 +- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index f544f2a73d..a175241d85 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -23,7 +23,7 @@ import type BNote from "../../becca/entities/bnote.js"; import type { Response } from "express"; import type { NoteMetaFile } from "../meta/note_meta.js"; import cssContent from "@triliumnext/ckeditor5/content.css"; -import { renderNoteContent } from "../../share/content_renderer.js"; +import { renderNoteForExport } from "../../share/content_renderer.js"; type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; @@ -332,7 +332,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h const htmlTitle = escapeHtml(title); if (note) { - content = renderNoteContent(note); + content = renderNoteForExport(note, branch); } else { // element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 content = ` diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index aad3ab9d2d..465c605cac 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -26,7 +26,12 @@ export interface Result { isEmpty?: boolean; } -function getSharedSubTreeRoot(note: SNote | BNote | undefined): { note?: SNote | BNote; branch?: SBranch | BBranch } { +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 {}; @@ -53,9 +58,21 @@ function getSharedSubTreeRoot(note: SNote | BNote | undefined): { note?: SNote | return getSharedSubTreeRoot(parentBranch.getParentNote()); } -export function renderNoteContent(note: SNote | BNote) { - const { header, content, isEmpty } = getContent(note); +export function renderNoteForExport(note: BNote, parentBranch: BBranch) { + const subRoot: Subroot = { + branch: parentBranch, + note: parentBranch.getNote() + }; + return renderNoteContentInternal(note, subRoot, note.getParentNotes()[0].noteId); +} + +export function renderNoteContent(note: SNote) { const subRoot = getSharedSubTreeRoot(note); + return renderNoteContentInternal(note, subRoot, "_share"); +} + +function renderNoteContentInternal(note: SNote | BNote, subRoot: Subroot, rootNoteId: string) { + const { header, content, isEmpty } = getContent(note); const showLoginInShareTheme = options.getOption("showLoginInShareTheme"); const opts = { note, @@ -68,7 +85,8 @@ export function renderNoteContent(note: SNote | BNote) { appPath: isDev ? app_path : `../${app_path}`, showLoginInShareTheme, t, - isDev + isDev, + rootNoteId }; // Check if the user has their own template. diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index 5c39051ebf..ba3d45b440 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -108,7 +108,7 @@ content = content.replaceAll(headingRe, (...match) => { <% const ancestors = []; let notePointer = note; - while (notePointer.parents[0].noteId !== "_share") { + while (notePointer.parents[0].noteId !== rootNoteId) { const pointerParent = notePointer.parents[0]; ancestors.push(pointerParent.noteId); notePointer = pointerParent; From 4d5e866db6f1d526a4f1b5e1947e4cc227fed1d8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 13 Jun 2025 23:47:04 +0300 Subject: [PATCH 004/360] feat(export/zip): get CSS to load --- apps/server/src/services/export/zip.ts | 25 +++++++++--- apps/server/src/share/content_renderer.ts | 45 +++++++++++++++++---- packages/share-theme/src/templates/page.ejs | 10 +---- 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index a175241d85..8d8d2de074 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -2,11 +2,11 @@ import html from "html"; import dateUtils from "../date_utils.js"; -import path from "path"; +import path, { join } from "path"; import mimeTypes from "mime-types"; import mdService from "./markdown.js"; import packageInfo from "../../../package.json" with { type: "json" }; -import { getContentDisposition, escapeHtml, getResourceDir } from "../utils.js"; +import { getContentDisposition, escapeHtml, getResourceDir, isDev } from "../utils.js"; import protectedSessionService from "../protected_session.js"; import sanitize from "sanitize-filename"; import fs from "fs"; @@ -22,7 +22,7 @@ import type BBranch from "../../becca/entities/bbranch.js"; import type BNote from "../../becca/entities/bnote.js"; import type { Response } from "express"; import type { NoteMetaFile } from "../meta/note_meta.js"; -import cssContent from "@triliumnext/ckeditor5/content.css"; +//import cssContent from "@triliumnext/ckeditor5/content.css"; import { renderNoteForExport } from "../../share/content_renderer.js"; type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; @@ -328,12 +328,13 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h throw new Error("Missing note path."); } - const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`; + const basePath = "../".repeat(noteMeta.notePath.length - 1); const htmlTitle = escapeHtml(title); if (note) { - content = renderNoteForExport(note, branch); + content = renderNoteForExport(note, branch, basePath); } else { + const cssUrl = basePath + "style.css"; // element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 content = ` @@ -518,6 +519,7 @@ ${markdownContent}`; return; } + let cssContent = getShareThemeAssets("css"); archive.append(cssContent, { name: cssMeta.dataFileName }); } @@ -629,6 +631,19 @@ async function exportToZipFile(noteId: string, format: "markdown" | "html", zipF log.info(`Exported '${noteId}' with format '${format}' to '${zipFilePath}'`); } +function getShareThemeAssets(extension: string) { + let path: string | undefined; + if (isDev) { + path = join(getResourceDir(), "..", "..", "client", "dist", "src", `share.${extension}`); + } + + if (!path) { + throw new Error("Not yet defined."); + } + + return fs.readFileSync(path, "utf-8"); +} + export default { exportToZip, exportToZipFile diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 465c605cac..4fbae0586a 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -16,6 +16,8 @@ import log from "../services/log.js"; import { join } from "path"; import { readFileSync } from "fs"; +const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`; + /** * Represents the output of the content renderer. */ @@ -58,20 +60,50 @@ function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot { return getSharedSubTreeRoot(parentBranch.getParentNote()); } -export function renderNoteForExport(note: BNote, parentBranch: BBranch) { +export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string) { const subRoot: Subroot = { branch: parentBranch, note: parentBranch.getNote() }; - return renderNoteContentInternal(note, subRoot, note.getParentNotes()[0].noteId); + + return renderNoteContentInternal(note, { + subRoot, + rootNoteId: note.getParentNotes()[0].noteId, + cssToLoad: [ + `${basePath}style.css` + ] + }); } export function renderNoteContent(note: SNote) { const subRoot = getSharedSubTreeRoot(note); - return renderNoteContentInternal(note, subRoot, "_share"); + + // Determine CSS to load. + const cssToLoad: string[] = []; + if (!isDev && !note.isLabelTruthy("shareOmitDefaultCss")) { + cssToLoad.push(`${shareAdjustedAssetPath}/src/share.css`); + cssToLoad.push(`${shareAdjustedAssetPath}/src/boxicons.css`); + } + + // Support custom CSS too. + for (const cssRelation of note.getRelations("shareCss")) { + cssToLoad.push(`api/notes/${cssRelation.value}/download`); + } + + return renderNoteContentInternal(note, { + subRoot, + rootNoteId: "_share", + cssToLoad + }); } -function renderNoteContentInternal(note: SNote | BNote, subRoot: Subroot, rootNoteId: string) { +interface RenderArgs { + subRoot: Subroot; + rootNoteId: string; + cssToLoad: string[]; +} + +function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) { const { header, content, isEmpty } = getContent(note); const showLoginInShareTheme = options.getOption("showLoginInShareTheme"); const opts = { @@ -79,14 +111,13 @@ function renderNoteContentInternal(note: SNote | BNote, subRoot: Subroot, rootNo header, content, isEmpty, - subRoot, - assetPath: isDev ? assetPath : `../${assetPath}`, + assetPath: shareAdjustedAssetPath, assetUrlFragment, appPath: isDev ? app_path : `../${app_path}`, showLoginInShareTheme, t, isDev, - rootNoteId + ...renderArgs }; // Check if the user has their own template. diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index ba3d45b440..820e32ad5f 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -6,14 +6,8 @@ api/notes/<%= note.getRelation("shareFavicon").value %>/download<% } else { %>../favicon.ico<% } %>"> - - <% if (!isDev && !note.isLabelTruthy("shareOmitDefaultCss")) { %> - - - <% } %> - - <% for (const cssRelation of note.getRelations("shareCss")) { %> - + <% for (const url of cssToLoad) { %> + <% } %> <% for (const jsRelation of note.getRelations("shareJs")) { %> From d8958adea5c13ced699db18fff9dace3db7689dd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 14 Jun 2025 00:07:55 +0300 Subject: [PATCH 005/360] feat(export/zip): basic tree navigation --- apps/server/src/services/export/zip.ts | 3 +++ packages/share-theme/src/templates/tree_item.ejs | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 8d8d2de074..bb3e382be0 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -333,6 +333,9 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h if (note) { content = renderNoteForExport(note, branch, basePath); + + // TODO: Fix double rewrite. + content = rewriteFn(content, noteMeta); } else { const cssUrl = basePath + "style.css"; // element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 diff --git a/packages/share-theme/src/templates/tree_item.ejs b/packages/share-theme/src/templates/tree_item.ejs index b033ad2bc1..99da978fa6 100644 --- a/packages/share-theme/src/templates/tree_item.ejs +++ b/packages/share-theme/src/templates/tree_item.ejs @@ -1,7 +1,16 @@ <% const linkClass = `type-${note.type}` + (activeNote.noteId === note.noteId ? " active" : ""); const isExternalLink = note.hasLabel("shareExternal"); -const linkHref = isExternalLink ? note.getLabelValue("shareExternal") : `./${note.shareId}`; +let linkHref; + +if (isExternalLink) { + linkHref = note.getLabelValue("shareExternal"); +} else if (note.shareId) { + linkHref = `./${note.shareId}`; +} else { + linkHref = `#${note.getBestNotePath().join("/")}`; +} + const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : ""; %> From 01a552ceb515ff62fd7ec17f9367ed52fac3c2ed Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 14 Jun 2025 00:52:56 +0300 Subject: [PATCH 006/360] feat(export/zip): get boxicons to work --- apps/server/src/services/export/zip.ts | 53 +++++++++++++++-------- apps/server/src/share/content_renderer.ts | 3 +- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index bb3e382be0..62a1be37e1 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -517,13 +517,15 @@ ${markdownContent}`; archive.append(fullHtml, { name: indexMeta.dataFileName }); } - function saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) { - if (!cssMeta.dataFileName) { - return; - } + function saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) { + for (const assetMeta of assetsMeta) { + if (!assetMeta.dataFileName) { + continue; + } - let cssContent = getShareThemeAssets("css"); - archive.append(cssContent, { name: cssMeta.dataFileName }); + let cssContent = getShareThemeAssets(assetMeta.dataFileName); + archive.append(cssContent, { name: assetMeta.dataFileName }); + } } const existingFileNames: Record = format === "html" ? { navigation: 0, index: 1 } : {}; @@ -540,7 +542,7 @@ ${markdownContent}`; let navigationMeta: NoteMeta | null = null; let indexMeta: NoteMeta | null = null; - let cssMeta: NoteMeta | null = null; + let assetsMeta: NoteMeta[] = []; if (format === "html") { navigationMeta = { @@ -557,12 +559,24 @@ ${markdownContent}`; metaFile.files.push(indexMeta); - cssMeta = { - noImport: true, - dataFileName: "style.css" - }; + const assets = [ + "style.css", + "boxicons.css", + "boxicons.eot", + "boxicons.woff2", + "boxicons.woff", + "boxicons.ttf", + "boxicons.svg", + ]; - metaFile.files.push(cssMeta); + for (const asset of assets) { + const assetMeta = { + noImport: true, + dataFileName: asset + }; + assetsMeta.push(assetMeta); + metaFile.files.push(assetMeta); + } } for (const noteMeta of Object.values(noteIdToMeta)) { @@ -596,13 +610,13 @@ ${markdownContent}`; saveNote(rootMeta, ""); if (format === "html") { - if (!navigationMeta || !indexMeta || !cssMeta) { + if (!navigationMeta || !indexMeta || !assetsMeta) { throw new Error("Missing meta."); } saveNavigation(rootMeta, navigationMeta); saveIndex(rootMeta, indexMeta); - saveCss(rootMeta, cssMeta); + saveAssets(rootMeta, assetsMeta); } const note = branch.getNote(); @@ -634,17 +648,22 @@ async function exportToZipFile(noteId: string, format: "markdown" | "html", zipF log.info(`Exported '${noteId}' with format '${format}' to '${zipFilePath}'`); } -function getShareThemeAssets(extension: string) { +function getShareThemeAssets(nameWithExtension: string) { + // Rename share.css to style.css. + if (nameWithExtension === "style.css") { + nameWithExtension = "share.css"; + } + let path: string | undefined; if (isDev) { - path = join(getResourceDir(), "..", "..", "client", "dist", "src", `share.${extension}`); + path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension); } if (!path) { throw new Error("Not yet defined."); } - return fs.readFileSync(path, "utf-8"); + return fs.readFileSync(path); } export default { diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 4fbae0586a..0a85f17d22 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -70,7 +70,8 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath subRoot, rootNoteId: note.getParentNotes()[0].noteId, cssToLoad: [ - `${basePath}style.css` + `${basePath}style.css`, + `${basePath}boxicons.css` ] }); } From d3115e834ad808071d3c52ac4f7bce0f2d4520bb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 14 Jun 2025 01:01:12 +0300 Subject: [PATCH 007/360] feat(export/zip): get logo to work --- apps/server/src/services/export/zip.ts | 6 +++++- apps/server/src/share/content_renderer.ts | 10 ++++++++-- packages/share-theme/src/templates/page.ejs | 2 -- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 62a1be37e1..046b7369c5 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -24,6 +24,7 @@ import type { Response } from "express"; import type { NoteMetaFile } from "../meta/note_meta.js"; //import cssContent from "@triliumnext/ckeditor5/content.css"; import { renderNoteForExport } from "../../share/content_renderer.js"; +import { RESOURCE_DIR } from "../resource_dir.js"; type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; @@ -567,6 +568,7 @@ ${markdownContent}`; "boxicons.woff", "boxicons.ttf", "boxicons.svg", + "icon-color.svg" ]; for (const asset of assets) { @@ -655,7 +657,9 @@ function getShareThemeAssets(nameWithExtension: string) { } let path: string | undefined; - if (isDev) { + if (nameWithExtension === "icon-color.svg") { + path = join(RESOURCE_DIR, "images", nameWithExtension); + } else if (isDev) { path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension); } diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 0a85f17d22..346d9743eb 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -72,7 +72,8 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath cssToLoad: [ `${basePath}style.css`, `${basePath}boxicons.css` - ] + ], + logoUrl: `${basePath}icon-color.svg` }); } @@ -91,10 +92,14 @@ export function renderNoteContent(note: SNote) { cssToLoad.push(`api/notes/${cssRelation.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 + cssToLoad, + logoUrl }); } @@ -102,6 +107,7 @@ interface RenderArgs { subRoot: Subroot; rootNoteId: string; cssToLoad: string[]; + logoUrl: string; } function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) { diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index 820e32ad5f..8b4609020d 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -49,8 +49,6 @@ <% -const customLogoId = subRoot.note.getRelation("shareLogo")?.value; -const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`; const logoWidth = subRoot.note.getLabelValue("shareLogoWidth") ?? 53; const logoHeight = subRoot.note.getLabelValue("shareLogoHeight") ?? 40; const mobileLogoHeight = logoHeight && logoWidth ? 32 / (logoWidth / logoHeight) : ""; From 01beebf660c8f11c0b0fe576b72a535524e0b107 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 14 Jun 2025 01:23:02 +0300 Subject: [PATCH 008/360] feat(export/zip): load script as well --- apps/server/src/services/export/zip.ts | 3 +++ apps/server/src/share/content_renderer.ts | 18 +++++++++++++++--- packages/share-theme/src/templates/page.ejs | 4 ++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 046b7369c5..7034f8e185 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -562,6 +562,7 @@ ${markdownContent}`; const assets = [ "style.css", + "script.js", "boxicons.css", "boxicons.eot", "boxicons.woff2", @@ -654,6 +655,8 @@ function getShareThemeAssets(nameWithExtension: string) { // Rename share.css to style.css. if (nameWithExtension === "style.css") { nameWithExtension = "share.css"; + } else if (nameWithExtension === "script.js") { + nameWithExtension = "share.js"; } let path: string | undefined; diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 346d9743eb..041e5cc175 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -17,6 +17,7 @@ import { join } from "path"; import { readFileSync } from "fs"; const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`; +const shareAdjustedAppPath = isDev ? app_path : `../${app_path}`; /** * Represents the output of the content renderer. @@ -73,6 +74,9 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath `${basePath}style.css`, `${basePath}boxicons.css` ], + jsToLoad: [ + `${basePath}script.js` + ], logoUrl: `${basePath}icon-color.svg` }); } @@ -86,12 +90,18 @@ export function renderNoteContent(note: SNote) { cssToLoad.push(`${shareAdjustedAssetPath}/src/share.css`); cssToLoad.push(`${shareAdjustedAssetPath}/src/boxicons.css`); } - - // Support custom CSS too. for (const cssRelation of note.getRelations("shareCss")) { cssToLoad.push(`api/notes/${cssRelation.value}/download`); } + // Determine JS to load. + const jsToLoad: string[] = [ + `${shareAdjustedAppPath}/share.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`; @@ -99,6 +109,7 @@ export function renderNoteContent(note: SNote) { subRoot, rootNoteId: "_share", cssToLoad, + jsToLoad, logoUrl }); } @@ -107,6 +118,7 @@ interface RenderArgs { subRoot: Subroot; rootNoteId: string; cssToLoad: string[]; + jsToLoad: string[]; logoUrl: string; } @@ -120,7 +132,7 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) isEmpty, assetPath: shareAdjustedAssetPath, assetUrlFragment, - appPath: isDev ? app_path : `../${app_path}`, + appPath: shareAdjustedAppPath, showLoginInShareTheme, t, isDev, diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index 8b4609020d..243f788a1d 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -9,8 +9,8 @@ <% for (const url of cssToLoad) { %> <% } %> - <% for (const jsRelation of note.getRelations("shareJs")) { %> - + <% for (const url of jsToLoad) { %> + <% } %> <% if (note.hasLabel("shareDisallowRobotIndexing")) { %> From c5196721d4ffe3ad0c05d99c5f85b5ee0ac022dd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 15:36:10 +0300 Subject: [PATCH 009/360] chore(nx): sync tsconfig --- apps/server/tsconfig.app.json | 3 --- apps/server/tsconfig.json | 3 --- 2 files changed, 6 deletions(-) diff --git a/apps/server/tsconfig.app.json b/apps/server/tsconfig.app.json index eb7f102aa4..61f4a77fe3 100644 --- a/apps/server/tsconfig.app.json +++ b/apps/server/tsconfig.app.json @@ -34,9 +34,6 @@ "src/**/*.spec.jsx" ], "references": [ - { - "path": "../../packages/ckeditor5/tsconfig.lib.json" - }, { "path": "../../packages/turndown-plugin-gfm/tsconfig.lib.json" }, diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 6bc2242953..baacd3fa56 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,9 +3,6 @@ "files": [], "include": [], "references": [ - { - "path": "../../packages/ckeditor5" - }, { "path": "../../packages/turndown-plugin-gfm" }, From dfd575b6ebb0be9d2c0b2aae962478eed634dcbf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 16:08:31 +0300 Subject: [PATCH 010/360] refactor(export/zip): extract into separate provider --- apps/server/src/services/export/zip.ts | 207 +++--------------- .../services/export/zip/abstract_provider.ts | 27 +++ apps/server/src/services/export/zip/html.ts | 135 ++++++++++++ 3 files changed, 188 insertions(+), 181 deletions(-) create mode 100644 apps/server/src/services/export/zip/abstract_provider.ts create mode 100644 apps/server/src/services/export/zip/html.ts diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 7034f8e185..6caffac86f 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -2,11 +2,11 @@ import html from "html"; import dateUtils from "../date_utils.js"; -import path, { join } from "path"; +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, escapeHtml } from "../utils.js"; import protectedSessionService from "../protected_session.js"; import sanitize from "sanitize-filename"; import fs from "fs"; @@ -19,12 +19,10 @@ 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 type BNote from "../../becca/entities/bnote.js"; import type { Response } from "express"; import type { NoteMetaFile } from "../meta/note_meta.js"; -//import cssContent from "@triliumnext/ckeditor5/content.css"; -import { renderNoteForExport } from "../../share/content_renderer.js"; -import { RESOURCE_DIR } from "../resource_dir.js"; +import HtmlExportProvider from "./zip/html.js"; +import { ZipExportProvider } from "./zip/abstract_provider.js"; type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; @@ -317,7 +315,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h } } - function prepareContent(note: BNote | undefined, title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { + function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { if (["html", "markdown"].includes(noteMeta?.format || "")) { content = content.toString(); content = rewriteFn(content, noteMeta); @@ -329,18 +327,11 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h throw new Error("Missing note path."); } - const basePath = "../".repeat(noteMeta.notePath.length - 1); + const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`; const htmlTitle = escapeHtml(title); - if (note) { - content = renderNoteForExport(note, branch, basePath); - - // TODO: Fix double rewrite. - content = rewriteFn(content, noteMeta); - } else { - const cssUrl = basePath + "style.css"; - // element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 - content = ` + // element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 + content = ` @@ -356,7 +347,6 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h `; - } } return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content; @@ -386,7 +376,7 @@ ${markdownContent}`; let content: string | Buffer = `

This is a clone of a note. Go to its primary location.

`; - content = prepareContent(undefined, noteMeta.title, content, noteMeta); + content = prepareContent(noteMeta.title, content, noteMeta); archive.append(content, { name: filePathPrefix + noteMeta.dataFileName }); @@ -402,7 +392,7 @@ ${markdownContent}`; } if (noteMeta.dataFileName) { - const content = prepareContent(note, noteMeta.title, note.getContent(), noteMeta); + const content = prepareContent(noteMeta.title, note.getContent(), noteMeta); archive.append(content, { name: filePathPrefix + noteMeta.dataFileName, @@ -438,97 +428,6 @@ ${markdownContent}`; } } - function saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) { - if (!navigationMeta.dataFileName) { - return; - } - - function saveNavigationInner(meta: NoteMeta) { - let html = "
  • "; - - const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`); - - if (meta.dataFileName && meta.noteId) { - const targetUrl = getNoteTargetUrl(meta.noteId, rootMeta); - - html += `${escapedTitle}`; - } else { - html += escapedTitle; - } - - if (meta.children && meta.children.length > 0) { - html += "
      "; - - for (const child of meta.children) { - html += saveNavigationInner(child); - } - - html += "
    "; - } - - return `${html}
  • `; - } - - const fullHtml = ` - - - - - -
      ${saveNavigationInner(rootMeta)}
    - -`; - const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml; - - archive.append(prettyHtml, { name: navigationMeta.dataFileName }); - } - - function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) { - let firstNonEmptyNote; - let curMeta = 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 = ` - - - - - - - - - -`; - - archive.append(fullHtml, { name: indexMeta.dataFileName }); - } - - function saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) { - for (const assetMeta of assetsMeta) { - if (!assetMeta.dataFileName) { - continue; - } - - let cssContent = getShareThemeAssets(assetMeta.dataFileName); - archive.append(cssContent, { name: assetMeta.dataFileName }); - } - } - const existingFileNames: Record = format === "html" ? { navigation: 0, index: 1 } : {}; const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames); if (!rootMeta) { @@ -541,47 +440,23 @@ ${markdownContent}`; files: [rootMeta] }; - let navigationMeta: NoteMeta | null = null; - let indexMeta: NoteMeta | null = null; - let assetsMeta: NoteMeta[] = []; - - if (format === "html") { - navigationMeta = { - noImport: true, - dataFileName: "navigation.html" - }; - - metaFile.files.push(navigationMeta); - - indexMeta = { - noImport: true, - dataFileName: "index.html" - }; - - metaFile.files.push(indexMeta); - - const assets = [ - "style.css", - "script.js", - "boxicons.css", - "boxicons.eot", - "boxicons.woff2", - "boxicons.woff", - "boxicons.ttf", - "boxicons.svg", - "icon-color.svg" - ]; - - for (const asset of assets) { - const assetMeta = { - noImport: true, - dataFileName: asset - }; - assetsMeta.push(assetMeta); - metaFile.files.push(assetMeta); - } + let provider: ZipExportProvider; + switch (format) { + case "html": + provider = new HtmlExportProvider({ + getNoteTargetUrl, + metaFile, + archive, + rootMeta + }); + break; + case "markdown": + default: + throw new Error(); } + provider.prepareMeta(); + for (const noteMeta of Object.values(noteIdToMeta)) { // filter out relations which are not inside this export noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => { @@ -612,15 +487,7 @@ ${markdownContent}`; saveNote(rootMeta, ""); - if (format === "html") { - if (!navigationMeta || !indexMeta || !assetsMeta) { - throw new Error("Missing meta."); - } - - saveNavigation(rootMeta, navigationMeta); - saveIndex(rootMeta, indexMeta); - saveAssets(rootMeta, assetsMeta); - } + provider.afterDone(); const note = branch.getNote(); const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`; @@ -651,28 +518,6 @@ async function exportToZipFile(noteId: string, format: "markdown" | "html", zipF log.info(`Exported '${noteId}' with format '${format}' to '${zipFilePath}'`); } -function getShareThemeAssets(nameWithExtension: string) { - // Rename share.css to style.css. - if (nameWithExtension === "style.css") { - nameWithExtension = "share.css"; - } else if (nameWithExtension === "script.js") { - nameWithExtension = "share.js"; - } - - let path: string | undefined; - if (nameWithExtension === "icon-color.svg") { - path = join(RESOURCE_DIR, "images", nameWithExtension); - } else if (isDev) { - path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension); - } - - if (!path) { - throw new Error("Not yet defined."); - } - - return fs.readFileSync(path); -} - export default { exportToZip, exportToZipFile diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts new file mode 100644 index 0000000000..264dde0a7f --- /dev/null +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -0,0 +1,27 @@ +import { Archiver } from "archiver"; +import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js"; + +interface ZipExportProviderData { + getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; + metaFile: NoteMetaFile; + rootMeta: NoteMeta; + archive: Archiver; +} + +export abstract class ZipExportProvider { + + metaFile: NoteMetaFile; + getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; + rootMeta: NoteMeta; + archive: Archiver; + + constructor(data: ZipExportProviderData) { + this.metaFile = data.metaFile; + this.getNoteTargetUrl = data.getNoteTargetUrl; + this.rootMeta = data.rootMeta; + this.archive = data.archive; + } + + abstract prepareMeta(): void; + abstract afterDone(): void; +} diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts new file mode 100644 index 0000000000..517552e1da --- /dev/null +++ b/apps/server/src/services/export/zip/html.ts @@ -0,0 +1,135 @@ +import type NoteMeta from "../../meta/note_meta.js"; +import { escapeHtml } from "../../utils"; +import cssContent from "@triliumnext/ckeditor5/content.css"; +import html from "html"; +import { ZipExportProvider } from "./abstract_provider.js"; + +export default class HtmlExportProvider extends ZipExportProvider { + + private navigationMeta: NoteMeta | null = null; + private indexMeta: NoteMeta | null = null; + private cssMeta: NoteMeta | null = null; + + prepareMeta() { + this.navigationMeta = { + noImport: true, + dataFileName: "navigation.html" + }; + + this.metaFile.files.push(this.navigationMeta); + + this.indexMeta = { + noImport: true, + dataFileName: "index.html" + }; + + this.metaFile.files.push(this.indexMeta); + + this.cssMeta = { + noImport: true, + dataFileName: "style.css" + }; + + this.metaFile.files.push(this.cssMeta); + } + + afterDone() { + if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) { + throw new Error("Missing meta."); + } + + this.#saveNavigation(this.rootMeta, this.navigationMeta); + this.#saveIndex(this.rootMeta, this.indexMeta); + this.#saveCss(this.rootMeta, this.cssMeta); + } + + #saveNavigationInner(meta: NoteMeta) { + let html = "
  • "; + + const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`); + + if (meta.dataFileName && meta.noteId) { + const targetUrl = this.getNoteTargetUrl(meta.noteId, this.rootMeta); + + html += `${escapedTitle}`; + } else { + html += escapedTitle; + } + + if (meta.children && meta.children.length > 0) { + html += "
      "; + + for (const child of meta.children) { + html += this.#saveNavigationInner(child); + } + + html += "
    "; + } + + return `${html}
  • `; + } + + #saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) { + if (!navigationMeta.dataFileName) { + return; + } + + const fullHtml = ` + + + + + +
      ${this.#saveNavigationInner(rootMeta)}
    + + `; + 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 = ` + + + + + + + + + +`; + + this.archive.append(fullHtml, { name: indexMeta.dataFileName }); + } + + #saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) { + if (!cssMeta.dataFileName) { + return; + } + + this.archive.append(cssContent, { name: cssMeta.dataFileName }); + } + +} + From e529633b8bde4a36b83c982d24c1c397dcfb6bf1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 16:17:29 +0300 Subject: [PATCH 011/360] chore(export/zip): bring back markdown exporter --- apps/server/src/services/export/zip.ts | 18 +++++++++++------- .../services/export/zip/abstract_provider.ts | 2 +- .../server/src/services/export/zip/markdown.ts | 8 ++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/services/export/zip/markdown.ts diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 6caffac86f..f6c131d879 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -22,7 +22,8 @@ import type 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 { ZipExportProvider } from "./zip/abstract_provider.js"; +import { ZipExportProvider, ZipExportProviderData } from "./zip/abstract_provider.js"; +import MarkdownExportProvider from "./zip/markdown.js"; type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; @@ -441,16 +442,19 @@ ${markdownContent}`; }; let provider: ZipExportProvider; + const providerData: ZipExportProviderData = { + getNoteTargetUrl, + metaFile, + archive, + rootMeta + }; switch (format) { case "html": - provider = new HtmlExportProvider({ - getNoteTargetUrl, - metaFile, - archive, - rootMeta - }); + provider = new HtmlExportProvider(providerData); break; case "markdown": + provider = new MarkdownExportProvider(providerData); + break; default: throw new Error(); } diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index 264dde0a7f..5f35021079 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -1,7 +1,7 @@ import { Archiver } from "archiver"; import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js"; -interface ZipExportProviderData { +export interface ZipExportProviderData { getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; metaFile: NoteMetaFile; rootMeta: NoteMeta; diff --git a/apps/server/src/services/export/zip/markdown.ts b/apps/server/src/services/export/zip/markdown.ts new file mode 100644 index 0000000000..2f8ac13bcb --- /dev/null +++ b/apps/server/src/services/export/zip/markdown.ts @@ -0,0 +1,8 @@ +import { ZipExportProvider } from "./abstract_provider" + +export default class MarkdownExportProvider extends ZipExportProvider { + + prepareMeta() { } + afterDone() { } + +} From 55bb2fdb9b855e68a1df6006e9858b8eb5287052 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 16:22:42 +0300 Subject: [PATCH 012/360] refactor(export/zip): extract prepare content into providers --- apps/server/src/services/export/zip.ts | 64 +------------------ .../services/export/zip/abstract_provider.ts | 22 +++++++ apps/server/src/services/export/zip/html.ts | 35 ++++++++++ .../src/services/export/zip/markdown.ts | 18 ++++++ 4 files changed, 77 insertions(+), 62 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index f6c131d879..84d84871c2 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -1,10 +1,8 @@ "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 } from "../utils.js"; import protectedSessionService from "../protected_session.js"; @@ -22,27 +20,9 @@ import type 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 { ZipExportProvider, ZipExportProviderData } from "./zip/abstract_provider.js"; +import { AdvancedExportOptions, ZipExportProvider, ZipExportProviderData } from "./zip/abstract_provider.js"; import MarkdownExportProvider from "./zip/markdown.js"; -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 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, 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`); @@ -322,47 +302,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h content = rewriteFn(content, noteMeta); } - if (noteMeta.format === "html" && typeof content === "string") { - if (!content.substr(0, 100).toLowerCase().includes(" element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 - content = ` - - - - - - ${htmlTitle} - - -
    -

    ${htmlTitle}

    - -
    ${content}
    -
    - -`; - } - - 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 provider.prepareContent(title, content, noteMeta); } function saveNote(noteMeta: NoteMeta, filePathPrefix: string) { diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index 5f35021079..ceadc0ec10 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -1,11 +1,30 @@ import { Archiver } from "archiver"; import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js"; +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 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 { getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; metaFile: NoteMetaFile; rootMeta: NoteMeta; archive: Archiver; + zipExportOptions?: AdvancedExportOptions; } export abstract class ZipExportProvider { @@ -14,14 +33,17 @@ export abstract class ZipExportProvider { getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; rootMeta: NoteMeta; archive: Archiver; + zipExportOptions?: AdvancedExportOptions; constructor(data: ZipExportProviderData) { this.metaFile = data.metaFile; this.getNoteTargetUrl = data.getNoteTargetUrl; this.rootMeta = data.rootMeta; this.archive = data.archive; + this.zipExportOptions = data.zipExportOptions; } abstract prepareMeta(): void; + abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer; abstract afterDone(): void; } diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts index 517552e1da..0eac07fb89 100644 --- a/apps/server/src/services/export/zip/html.ts +++ b/apps/server/src/services/export/zip/html.ts @@ -33,6 +33,41 @@ export default class HtmlExportProvider extends ZipExportProvider { this.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(" element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 + content = ` + + + + + + ${htmlTitle} + + +
    +

    ${htmlTitle}

    + +
    ${content}
    +
    + +`; + } + + return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content; + } else { + return content; + } + } + afterDone() { if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) { throw new Error("Missing meta."); diff --git a/apps/server/src/services/export/zip/markdown.ts b/apps/server/src/services/export/zip/markdown.ts index 2f8ac13bcb..9143e5e1f6 100644 --- a/apps/server/src/services/export/zip/markdown.ts +++ b/apps/server/src/services/export/zip/markdown.ts @@ -1,8 +1,26 @@ +import NoteMeta from "../../meta/note_meta" import { ZipExportProvider } from "./abstract_provider" +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}`; + } + + return markdownContent; + } else { + return content; + } + } + afterDone() { } } From a9f68f548778e828b2f7d0e1a72198b8ae939330 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 18:13:47 +0300 Subject: [PATCH 013/360] feat(export/zip): add option to export with share theme --- apps/client/src/widgets/dialogs/export.ts | 7 ++ apps/server/src/etapi/notes.ts | 2 +- apps/server/src/routes/api/export.ts | 2 +- apps/server/src/services/export/zip.ts | 32 ++++--- .../services/export/zip/abstract_provider.ts | 4 +- .../src/services/export/zip/share_theme.ts | 86 +++++++++++++++++++ 6 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/services/export/zip/share_theme.ts diff --git a/apps/client/src/widgets/dialogs/export.ts b/apps/client/src/widgets/dialogs/export.ts index d9b13f4edc..ccc748d01e 100644 --- a/apps/client/src/widgets/dialogs/export.ts +++ b/apps/client/src/widgets/dialogs/export.ts @@ -85,6 +85,13 @@ const TPL = /*html*/` + +
    + +
    diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts index 973ec04afe..82280d0b91 100644 --- a/apps/server/src/etapi/notes.ts +++ b/apps/server/src/etapi/notes.ts @@ -147,7 +147,7 @@ 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)) { + 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) or 'markdown'.`); } diff --git a/apps/server/src/routes/api/export.ts b/apps/server/src/routes/api/export.ts index 7433cd5525..b2909f2881 100644 --- a/apps/server/src/routes/api/export.ts +++ b/apps/server/src/routes/api/export.ts @@ -26,7 +26,7 @@ function exportBranch(req: Request, res: Response) { const taskContext = new TaskContext(taskId, "export"); 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") { diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 84d84871c2..de84a580d4 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -4,7 +4,7 @@ import dateUtils from "../date_utils.js"; import path from "path"; import mimeTypes from "mime-types"; import packageInfo from "../../../package.json" with { type: "json" }; -import { getContentDisposition, escapeHtml } from "../utils.js"; +import { getContentDisposition } from "../utils.js"; import protectedSessionService from "../protected_session.js"; import sanitize from "sanitize-filename"; import fs from "fs"; @@ -22,9 +22,11 @@ import type { NoteMetaFile } from "../meta/note_meta.js"; import HtmlExportProvider from "./zip/html.js"; import { AdvancedExportOptions, ZipExportProvider, 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"; -async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { - if (!["html", "markdown"].includes(format)) { +async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "html" | "markdown" | "share", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { + if (!["html", "markdown", "share"].includes(format)) { throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`); } @@ -135,7 +137,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h prefix: branch.prefix, dataFileName: fileName, type: "text", // export will have text description - format: format + format: (format === "markdown" ? "markdown" : "html") }; return meta; } @@ -165,7 +167,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h taskContext.increaseProgressCount(); if (note.type === "text") { - meta.format = format; + meta.format = (format === "markdown" ? "markdown" : "html"); } noteIdToMeta[note.noteId] = meta as NoteMeta; @@ -296,13 +298,18 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h } } - 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); } - return provider.prepareContent(title, content, noteMeta); + content = provider.prepareContent(title, content, noteMeta, note, branch); + if (isText) { + content = rewriteFn(content as string, noteMeta); + } + + return content; } function saveNote(noteMeta: NoteMeta, filePathPrefix: string) { @@ -317,7 +324,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h let content: string | Buffer = `

    This is a clone of a note. Go to its primary location.

    `; - content = prepareContent(noteMeta.title, content, noteMeta); + content = prepareContent(noteMeta.title, content, noteMeta, undefined); archive.append(content, { name: filePathPrefix + noteMeta.dataFileName }); @@ -333,7 +340,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h } 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, @@ -395,6 +402,9 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h case "markdown": provider = new MarkdownExportProvider(providerData); break; + case "share": + provider = new ShareThemeExportProvider(providerData); + break; default: throw new Error(); } diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index ceadc0ec10..ba57ba69f5 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -1,5 +1,7 @@ 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"; type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; @@ -44,6 +46,6 @@ export abstract class ZipExportProvider { } abstract prepareMeta(): void; - abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer; + abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer; abstract afterDone(): void; } diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts new file mode 100644 index 0000000000..50339d20ad --- /dev/null +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -0,0 +1,86 @@ +import { join } from "path"; +import NoteMeta from "../../meta/note_meta"; +import { ZipExportProvider } from "./abstract_provider"; +import { RESOURCE_DIR } from "../../resource_dir"; +import { getResourceDir, isDev } from "../../utils"; +import fs from "fs"; +import { renderNoteForExport } from "../../../share/content_renderer"; +import type BNote from "../../../becca/entities/bnote.js"; +import type BBranch from "../../../becca/entities/bbranch.js"; + +export default class ShareThemeExportProvider extends ZipExportProvider { + + private assetsMeta: NoteMeta[] = []; + + prepareMeta(): void { + const assets = [ + "style.css", + "script.js", + "boxicons.css", + "boxicons.eot", + "boxicons.woff2", + "boxicons.woff", + "boxicons.ttf", + "boxicons.svg", + "icon-color.svg" + ]; + + for (const asset of assets) { + const assetMeta = { + noImport: true, + dataFileName: asset + }; + this.assetsMeta.push(assetMeta); + this.metaFile.files.push(assetMeta); + } + } + + prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote, branch: BBranch): string | Buffer { + if (!noteMeta?.notePath?.length) { + throw new Error("Missing note path."); + } + const basePath = "../".repeat(noteMeta.notePath.length - 1); + + content = renderNoteForExport(note, branch, basePath); + + return content; + } + + afterDone(): void { + this.#saveAssets(this.rootMeta, this.assetsMeta); + } + + #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) { + // Rename share.css to style.css. + if (nameWithExtension === "style.css") { + nameWithExtension = "share.css"; + } else if (nameWithExtension === "script.js") { + nameWithExtension = "share.js"; + } + + let path: string | undefined; + if (nameWithExtension === "icon-color.svg") { + path = join(RESOURCE_DIR, "images", nameWithExtension); + } else if (isDev) { + path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension); + } + + if (!path) { + throw new Error("Not yet defined."); + } + + return fs.readFileSync(path); +} From acb0991d054100350d5b80a242025b830f947e15 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 18:24:59 +0300 Subject: [PATCH 014/360] refactor(export/zip): separate building provider into own method --- apps/server/src/services/export/zip.ts | 42 +++++++++---------- .../src/services/export/zip/share_theme.ts | 6 ++- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index de84a580d4..fe767662bb 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -20,7 +20,7 @@ import type 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, ZipExportProvider, ZipExportProviderData } from "./zip/abstract_provider.js"; +import { AdvancedExportOptions, 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"; @@ -36,6 +36,25 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h const noteIdToMeta: Record = {}; + function buildProvider() { + const providerData: ZipExportProviderData = { + getNoteTargetUrl, + metaFile, + archive, + rootMeta: rootMeta! + }; + 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, fileName: string) { const lcFileName = fileName.toLowerCase(); @@ -388,26 +407,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h files: [rootMeta] }; - let provider: ZipExportProvider; - const providerData: ZipExportProviderData = { - getNoteTargetUrl, - metaFile, - archive, - rootMeta - }; - switch (format) { - case "html": - provider = new HtmlExportProvider(providerData); - break; - case "markdown": - provider = new MarkdownExportProvider(providerData); - break; - case "share": - provider = new ShareThemeExportProvider(providerData); - break; - default: - throw new Error(); - } + const provider= buildProvider(); provider.prepareMeta(); diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 50339d20ad..07dbf5f7cb 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -35,13 +35,15 @@ export default class ShareThemeExportProvider extends ZipExportProvider { } } - prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote, branch: BBranch): string | Buffer { + 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); - content = renderNoteForExport(note, branch, basePath); + if (note) { + content = renderNoteForExport(note, branch, basePath); + } return content; } From 0efdf65202f7dc48dd9e69a89b4f3846f6d6c887 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 18:46:21 +0300 Subject: [PATCH 015/360] refactor(export/share): build index file --- apps/server/src/services/export/zip.ts | 5 +++-- .../services/export/zip/abstract_provider.ts | 4 +++- .../src/services/export/zip/share_theme.ts | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index fe767662bb..9a11ca026a 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -16,7 +16,7 @@ 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"; @@ -41,7 +41,8 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h getNoteTargetUrl, metaFile, archive, - rootMeta: rootMeta! + rootMeta: rootMeta!, + branch }; switch (format) { case "html": diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index ba57ba69f5..d1bd7a9f95 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -22,6 +22,7 @@ export interface AdvancedExportOptions { } export interface ZipExportProviderData { + branch: BBranch; getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; metaFile: NoteMetaFile; rootMeta: NoteMeta; @@ -30,7 +31,7 @@ export interface ZipExportProviderData { } export abstract class ZipExportProvider { - + branch: BBranch; metaFile: NoteMetaFile; getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; rootMeta: NoteMeta; @@ -38,6 +39,7 @@ export abstract class ZipExportProvider { zipExportOptions?: AdvancedExportOptions; constructor(data: ZipExportProviderData) { + this.branch = data.branch; this.metaFile = data.metaFile; this.getNoteTargetUrl = data.getNoteTargetUrl; this.rootMeta = data.rootMeta; diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 07dbf5f7cb..04a4a633fd 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -11,6 +11,7 @@ import type BBranch from "../../../becca/entities/bbranch.js"; export default class ShareThemeExportProvider extends ZipExportProvider { private assetsMeta: NoteMeta[] = []; + private indexMeta: NoteMeta | null = null; prepareMeta(): void { const assets = [ @@ -33,6 +34,13 @@ export default class ShareThemeExportProvider extends ZipExportProvider { this.assetsMeta.push(assetMeta); this.metaFile.files.push(assetMeta); } + + this.indexMeta = { + noImport: true, + dataFileName: "index.html" + }; + + this.metaFile.files.push(this.indexMeta); } prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer { @@ -50,6 +58,17 @@ export default class ShareThemeExportProvider extends ZipExportProvider { afterDone(): void { this.#saveAssets(this.rootMeta, this.assetsMeta); + this.#saveIndex(); + } + + #saveIndex() { + if (!this.indexMeta?.dataFileName) { + return; + } + + const note = this.branch.getNote(); + const fullHtml = this.prepareContent(this.rootMeta.title ?? "", note.getContent(), this.rootMeta, note, this.branch); + this.archive.append(fullHtml, { name: this.indexMeta.dataFileName }); } #saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) { From 8523050ab2ed02ce75eedd30ef1f5efd22c22579 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 19:00:20 +0300 Subject: [PATCH 016/360] fix(export/share): note children preview links not working --- apps/server/src/services/export/zip.ts | 9 +++------ apps/server/src/services/export/zip/abstract_provider.ts | 3 +++ apps/server/src/services/export/zip/html.ts | 6 +++++- apps/server/src/services/export/zip/markdown.ts | 1 + apps/server/src/services/export/zip/share_theme.ts | 1 + packages/share-theme/src/templates/page.ejs | 2 +- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 9a11ca026a..2f550898a1 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -35,6 +35,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h }); const noteIdToMeta: Record = {}; + const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks); function buildProvider() { const providerData: ZipExportProviderData = { @@ -42,7 +43,8 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h metaFile, archive, rootMeta: rootMeta!, - branch + branch, + rewriteFn }; switch (format) { case "html": @@ -275,8 +277,6 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h 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); @@ -325,9 +325,6 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h } content = provider.prepareContent(title, content, noteMeta, note, branch); - if (isText) { - content = rewriteFn(content as string, noteMeta); - } return content; } diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index d1bd7a9f95..0c7a536561 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -28,6 +28,7 @@ export interface ZipExportProviderData { rootMeta: NoteMeta; archive: Archiver; zipExportOptions?: AdvancedExportOptions; + rewriteFn: RewriteLinksFn; } export abstract class ZipExportProvider { @@ -37,6 +38,7 @@ export abstract class ZipExportProvider { rootMeta: NoteMeta; archive: Archiver; zipExportOptions?: AdvancedExportOptions; + rewriteFn: RewriteLinksFn; constructor(data: ZipExportProviderData) { this.branch = data.branch; @@ -45,6 +47,7 @@ export abstract class ZipExportProvider { this.rootMeta = data.rootMeta; this.archive = data.archive; this.zipExportOptions = data.zipExportOptions; + this.rewriteFn = data.rewriteFn; } abstract prepareMeta(): void; diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts index 0eac07fb89..749d7adc83 100644 --- a/apps/server/src/services/export/zip/html.ts +++ b/apps/server/src/services/export/zip/html.ts @@ -62,7 +62,11 @@ export default class HtmlExportProvider extends ZipExportProvider { `; } - return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content; + if (content.length < 100_000) { + content = html.prettyPrint(content, { indent_size: 2 }) + } + content = this.rewriteFn(content as string, noteMeta); + return content; } else { return content; } diff --git a/apps/server/src/services/export/zip/markdown.ts b/apps/server/src/services/export/zip/markdown.ts index 9143e5e1f6..1ace2051a5 100644 --- a/apps/server/src/services/export/zip/markdown.ts +++ b/apps/server/src/services/export/zip/markdown.ts @@ -15,6 +15,7 @@ export default class MarkdownExportProvider extends ZipExportProvider { ${markdownContent}`; } + markdownContent = this.rewriteFn(markdownContent, noteMeta); return markdownContent; } else { return content; diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 04a4a633fd..695d90e8da 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -51,6 +51,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { if (note) { content = renderNoteForExport(note, branch, basePath); + content = this.rewriteFn(content, noteMeta); } return content; diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index 243f788a1d..6900a1be05 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -137,7 +137,7 @@ content = content.replaceAll(headingRe, (...match) => { const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes"; for (const childNote of note[action]()) { const isExternalLink = childNote.hasLabel("shareExternal") || childNote.hasLabel("shareExternalLink"); - const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`; + const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId ?? "#root/" + childNote.noteId}`; const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : ""; %>
  • From 77e4c3d0ecb05f39e823932e6259819aca8f5748 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 19:25:28 +0300 Subject: [PATCH 017/360] refactor(export/share): use different URL rewriting mechanism --- apps/server/src/becca/entities/bnote.ts | 4 ++++ apps/server/src/services/export/zip/share_theme.ts | 1 + packages/share-theme/src/templates/page.ejs | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts index 6503167778..e6563f2dbc 100644 --- a/apps/server/src/becca/entities/bnote.ts +++ b/apps/server/src/becca/entities/bnote.ts @@ -1774,6 +1774,10 @@ class BNote extends AbstractBeccaEntity { return this.getVisibleChildNotes().length > 0; } + get shareId() { + return this.noteId; + } + } export default BNote; diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 695d90e8da..2b4ba72e83 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -51,6 +51,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { if (note) { content = renderNoteForExport(note, branch, basePath); + content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, "href=\"#root/$1\""); content = this.rewriteFn(content, noteMeta); } diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index 6900a1be05..243f788a1d 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -137,7 +137,7 @@ content = content.replaceAll(headingRe, (...match) => { const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes"; for (const childNote of note[action]()) { const isExternalLink = childNote.hasLabel("shareExternal") || childNote.hasLabel("shareExternalLink"); - const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId ?? "#root/" + childNote.noteId}`; + const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`; const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : ""; %>
  • From 35622a212253f09834ace19571118a28a93f423b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 19:38:47 +0300 Subject: [PATCH 018/360] feat(export/share): always render empty files --- apps/server/src/services/export/zip.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 2f550898a1..9c0f099d14 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -198,10 +198,13 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h 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); } From b475037127466737cc2eade8601798d8ef052d47 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 20:00:40 +0300 Subject: [PATCH 019/360] feat(export/share): render non-text note types --- apps/server/src/services/export/zip.ts | 38 +++---------------- .../services/export/zip/abstract_provider.ts | 37 ++++++++++++++---- apps/server/src/services/export/zip/html.ts | 27 ++++++------- .../src/services/export/zip/share_theme.ts | 22 ++++++----- 4 files changed, 60 insertions(+), 64 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 9c0f099d14..26af3424fb 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -2,7 +2,6 @@ import dateUtils from "../date_utils.js"; import path from "path"; -import mimeTypes from "mime-types"; import packageInfo from "../../../package.json" with { type: "json" }; import { getContentDisposition } from "../utils.js"; import protectedSessionService from "../protected_session.js"; @@ -33,16 +32,15 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h 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 = {}; - const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks); function buildProvider() { const providerData: ZipExportProviderData = { getNoteTargetUrl, - metaFile, archive, - rootMeta: rootMeta!, branch, rewriteFn }; @@ -94,36 +92,14 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h } 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); } @@ -408,9 +384,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h files: [rootMeta] }; - const provider= buildProvider(); - - provider.prepareMeta(); + provider.prepareMeta(metaFile); for (const noteMeta of Object.values(noteIdToMeta)) { // filter out relations which are not inside this export @@ -442,7 +416,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h saveNote(rootMeta, ""); - provider.afterDone(); + provider.afterDone(rootMeta); const note = branch.getNote(); const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`; diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index 0c7a536561..6ca5fdb9a4 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -2,6 +2,7 @@ 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"; type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; @@ -24,8 +25,6 @@ export interface AdvancedExportOptions { export interface ZipExportProviderData { branch: BBranch; getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; - metaFile: NoteMetaFile; - rootMeta: NoteMeta; archive: Archiver; zipExportOptions?: AdvancedExportOptions; rewriteFn: RewriteLinksFn; @@ -33,24 +32,46 @@ export interface ZipExportProviderData { export abstract class ZipExportProvider { branch: BBranch; - metaFile: NoteMetaFile; getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; - rootMeta: NoteMeta; archive: Archiver; zipExportOptions?: AdvancedExportOptions; rewriteFn: RewriteLinksFn; constructor(data: ZipExportProviderData) { this.branch = data.branch; - this.metaFile = data.metaFile; this.getNoteTargetUrl = data.getNoteTargetUrl; - this.rootMeta = data.rootMeta; this.archive = data.archive; this.zipExportOptions = data.zipExportOptions; this.rewriteFn = data.rewriteFn; } - abstract prepareMeta(): void; + abstract prepareMeta(metaFile: NoteMetaFile): void; abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer; - abstract afterDone(): void; + abstract afterDone(rootMeta: NoteMeta): void; + + mapExtension(type: string | null, mime: string, existingExtension: string, format: string) { + // 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"; + } + } + } + } diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts index 749d7adc83..8eb5c5d93c 100644 --- a/apps/server/src/services/export/zip/html.ts +++ b/apps/server/src/services/export/zip/html.ts @@ -10,27 +10,24 @@ export default class HtmlExportProvider extends ZipExportProvider { private indexMeta: NoteMeta | null = null; private cssMeta: NoteMeta | null = null; - prepareMeta() { + prepareMeta(metaFile) { this.navigationMeta = { noImport: true, dataFileName: "navigation.html" }; - - this.metaFile.files.push(this.navigationMeta); + metaFile.files.push(this.navigationMeta); this.indexMeta = { noImport: true, dataFileName: "index.html" }; - - this.metaFile.files.push(this.indexMeta); + metaFile.files.push(this.indexMeta); this.cssMeta = { noImport: true, dataFileName: "style.css" }; - - this.metaFile.files.push(this.cssMeta); + metaFile.files.push(this.cssMeta); } prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { @@ -72,23 +69,23 @@ export default class HtmlExportProvider extends ZipExportProvider { } } - afterDone() { + afterDone(rootMeta: NoteMeta) { if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) { throw new Error("Missing meta."); } - this.#saveNavigation(this.rootMeta, this.navigationMeta); - this.#saveIndex(this.rootMeta, this.indexMeta); - this.#saveCss(this.rootMeta, this.cssMeta); + this.#saveNavigation(rootMeta, this.navigationMeta); + this.#saveIndex(rootMeta, this.indexMeta); + this.#saveCss(rootMeta, this.cssMeta); } - #saveNavigationInner(meta: NoteMeta) { + #saveNavigationInner(rootMeta: NoteMeta, meta: NoteMeta) { let html = "
  • "; const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`); if (meta.dataFileName && meta.noteId) { - const targetUrl = this.getNoteTargetUrl(meta.noteId, this.rootMeta); + const targetUrl = this.getNoteTargetUrl(meta.noteId, rootMeta); html += `${escapedTitle}`; } else { @@ -99,7 +96,7 @@ export default class HtmlExportProvider extends ZipExportProvider { html += "
      "; for (const child of meta.children) { - html += this.#saveNavigationInner(child); + html += this.#saveNavigationInner(rootMeta, child); } html += "
    "; @@ -119,7 +116,7 @@ export default class HtmlExportProvider extends ZipExportProvider { -
      ${this.#saveNavigationInner(rootMeta)}
    +
      ${this.#saveNavigationInner(rootMeta, rootMeta)}
    `; const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml; diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 2b4ba72e83..abe7be42d7 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -1,5 +1,5 @@ import { join } from "path"; -import NoteMeta from "../../meta/note_meta"; +import NoteMeta, { NoteMetaFile } from "../../meta/note_meta"; import { ZipExportProvider } from "./abstract_provider"; import { RESOURCE_DIR } from "../../resource_dir"; import { getResourceDir, isDev } from "../../utils"; @@ -13,7 +13,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { private assetsMeta: NoteMeta[] = []; private indexMeta: NoteMeta | null = null; - prepareMeta(): void { + prepareMeta(metaFile: NoteMetaFile): void { const assets = [ "style.css", "script.js", @@ -32,7 +32,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { dataFileName: asset }; this.assetsMeta.push(assetMeta); - this.metaFile.files.push(assetMeta); + metaFile.files.push(assetMeta); } this.indexMeta = { @@ -40,7 +40,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { dataFileName: "index.html" }; - this.metaFile.files.push(this.indexMeta); + metaFile.files.push(this.indexMeta); } prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer { @@ -58,18 +58,22 @@ export default class ShareThemeExportProvider extends ZipExportProvider { return content; } - afterDone(): void { - this.#saveAssets(this.rootMeta, this.assetsMeta); - this.#saveIndex(); + afterDone(rootMeta: NoteMeta): void { + this.#saveAssets(rootMeta, this.assetsMeta); + this.#saveIndex(rootMeta); } - #saveIndex() { + mapExtension(_type: string | null, _mime: string, _existingExtension: string, _format: string): string | null { + return "html"; + } + + #saveIndex(rootMeta: NoteMeta) { if (!this.indexMeta?.dataFileName) { return; } const note = this.branch.getNote(); - const fullHtml = this.prepareContent(this.rootMeta.title ?? "", note.getContent(), this.rootMeta, note, this.branch); + const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch); this.archive.append(fullHtml, { name: this.indexMeta.dataFileName }); } From 61dbc15fc6e721d21e31a8e8d8d5304da9c291cc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 20:14:13 +0300 Subject: [PATCH 020/360] feat(export/share): use translation --- apps/client/src/translations/en/translation.json | 4 ++-- apps/client/src/widgets/dialogs/export.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 6d3ad07a29..dd02af8ef4 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -110,7 +110,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": { "fullDocumentation": "Help (full documentation is available online)", @@ -1197,7 +1198,6 @@ "restore_provider": "Restore provider to search", "similarity_threshold": "Similarity Threshold", "similarity_threshold_description": "Minimum similarity score (0-1) for notes to be included in context for LLM queries", - "reprocess_index": "Rebuild Search Index", "reprocessing_index": "Rebuilding...", "reprocess_index_started": "Search index optimization started in the background", diff --git a/apps/client/src/widgets/dialogs/export.ts b/apps/client/src/widgets/dialogs/export.ts index ccc748d01e..edf9a80ddc 100644 --- a/apps/client/src/widgets/dialogs/export.ts +++ b/apps/client/src/widgets/dialogs/export.ts @@ -89,7 +89,7 @@ const TPL = /*html*/`
  • From 9bc966491dfdc6d9ed98e9ae45afe48213c1588b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 21:22:45 +0300 Subject: [PATCH 021/360] fix(edit-docs): import error --- apps/edit-docs/src/edit-docs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edit-docs/src/edit-docs.ts b/apps/edit-docs/src/edit-docs.ts index 940f895400..db5d4be0c7 100644 --- a/apps/edit-docs/src/edit-docs.ts +++ b/apps/edit-docs/src/edit-docs.ts @@ -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 } 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"; From 413137ac6437b765ae0cda75ef5fda842f3a9412 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 23 Jun 2025 21:23:44 +0300 Subject: [PATCH 022/360] chore(nx): sync tsconfig --- apps/server/tsconfig.app.json | 3 +++ apps/server/tsconfig.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/server/tsconfig.app.json b/apps/server/tsconfig.app.json index 61f4a77fe3..eb7f102aa4 100644 --- a/apps/server/tsconfig.app.json +++ b/apps/server/tsconfig.app.json @@ -34,6 +34,9 @@ "src/**/*.spec.jsx" ], "references": [ + { + "path": "../../packages/ckeditor5/tsconfig.lib.json" + }, { "path": "../../packages/turndown-plugin-gfm/tsconfig.lib.json" }, diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index baacd3fa56..6bc2242953 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../../packages/ckeditor5" + }, { "path": "../../packages/turndown-plugin-gfm" }, From a2110ca631a0bf35424a7fd568d6d82cb72939bd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 24 Jun 2025 17:44:47 +0300 Subject: [PATCH 023/360] fix(export/share): tree not expanding properly --- .../src/services/export/zip/share_theme.ts | 4 ++-- apps/server/src/share/content_renderer.ts | 19 +++++++++++++++---- apps/server/src/share/routes.ts | 1 + packages/share-theme/src/templates/page.ejs | 11 +---------- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index abe7be42d7..59746626fd 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -1,6 +1,6 @@ import { join } from "path"; import NoteMeta, { NoteMetaFile } from "../../meta/note_meta"; -import { ZipExportProvider } from "./abstract_provider"; +import { ZipExportProvider } from "./abstract_provider.js"; import { RESOURCE_DIR } from "../../resource_dir"; import { getResourceDir, isDev } from "../../utils"; import fs from "fs"; @@ -50,7 +50,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { const basePath = "../".repeat(noteMeta.notePath.length - 1); if (note) { - content = renderNoteForExport(note, branch, basePath); + content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1)); content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, "href=\"#root/$1\""); content = this.rewriteFn(content, noteMeta); } diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 041e5cc175..62c9df71f2 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -61,7 +61,7 @@ function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot { return getSharedSubTreeRoot(parentBranch.getParentNote()); } -export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string) { +export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[]) { const subRoot: Subroot = { branch: parentBranch, note: parentBranch.getNote() @@ -69,7 +69,7 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath return renderNoteContentInternal(note, { subRoot, - rootNoteId: note.getParentNotes()[0].noteId, + rootNoteId: parentBranch.noteId, cssToLoad: [ `${basePath}style.css`, `${basePath}boxicons.css` @@ -77,13 +77,22 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath jsToLoad: [ `${basePath}script.js` ], - logoUrl: `${basePath}icon-color.svg` + 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]; + ancestors.push(pointerParent.noteId); + notePointer = pointerParent; + } + // Determine CSS to load. const cssToLoad: string[] = []; if (!isDev && !note.isLabelTruthy("shareOmitDefaultCss")) { @@ -110,7 +119,8 @@ export function renderNoteContent(note: SNote) { rootNoteId: "_share", cssToLoad, jsToLoad, - logoUrl + logoUrl, + ancestors }); } @@ -120,6 +130,7 @@ interface RenderArgs { cssToLoad: string[]; jsToLoad: string[]; logoUrl: string; + ancestors: string[]; } function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) { diff --git a/apps/server/src/share/routes.ts b/apps/server/src/share/routes.ts index 4b0281ec53..ceaeedb1bb 100644 --- a/apps/server/src/share/routes.ts +++ b/apps/server/src/share/routes.ts @@ -137,6 +137,7 @@ function register(router: Router) { return; } + res.send(renderNoteContent(note)); } diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index 243f788a1d..10a6c474a7 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -97,16 +97,7 @@ content = content.replaceAll(headingRe, (...match) => { <% if (hasTree) { %> <% } %> From bc4643fed2ed2937c501faf034fb59a31f0a2a65 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 24 Jun 2025 17:48:52 +0300 Subject: [PATCH 024/360] refactor(share): use internal rendering method for subtemplates --- apps/server/src/share/content_renderer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 62c9df71f2..529db21168 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -185,14 +185,18 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) // Render with the default view otherwise. const templatePath = join(getResourceDir(), "share-theme", "templates", "page.ejs"); - return ejs.render(readFileSync(templatePath, "utf-8"), opts, { + return ejs.render(readTemplate(templatePath), opts, { includer: (path) => { const templatePath = join(getResourceDir(), "share-theme", "templates", `${path}.ejs`); - return { filename: templatePath } + return { template: readTemplate(templatePath) }; } }); } +function readTemplate(path: string) { + return readFileSync(path, "utf-8"); +} + function getContent(note: SNote | BNote) { if (note.isProtected) { return { From 3a55490bbf859912648aabdcb7d8250a904c5720 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 24 Jun 2025 18:08:29 +0300 Subject: [PATCH 025/360] refactor(share): use a string cache for templates --- apps/server/src/services/export/zip/share_theme.ts | 6 ++++-- apps/server/src/share/content_renderer.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 59746626fd..efde0b5f5f 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -51,8 +51,10 @@ export default class ShareThemeExportProvider extends ZipExportProvider { if (note) { content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1)); - content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, "href=\"#root/$1\""); - content = this.rewriteFn(content, noteMeta); + if (typeof content === "string") { + content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, "href=\"#root/$1\""); + content = this.rewriteFn(content, noteMeta); + } } return content; diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 529db21168..d5c3a4c9e4 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -18,6 +18,7 @@ import { readFileSync } from "fs"; const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`; const shareAdjustedAppPath = isDev ? app_path : `../${app_path}`; +const templateCache: Map = new Map(); /** * Represents the output of the content renderer. @@ -194,7 +195,14 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) } function readTemplate(path: string) { - return readFileSync(path, "utf-8"); + const cachedTemplate = templateCache.get(path); + if (cachedTemplate) { + return cachedTemplate; + } + + const templateString = readFileSync(path, "utf-8"); + templateCache.set(path, templateString); + return templateString; } function getContent(note: SNote | BNote) { From 6d446c5b275ea116b7d65b88011328de3f3570f8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 24 Jun 2025 18:49:11 +0300 Subject: [PATCH 026/360] fix(export/share): asset path in prod --- apps/server/src/services/export/zip/share_theme.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index efde0b5f5f..f8475b0c2c 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -105,10 +105,8 @@ function getShareThemeAssets(nameWithExtension: string) { path = join(RESOURCE_DIR, "images", nameWithExtension); } else if (isDev) { path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension); - } - - if (!path) { - throw new Error("Not yet defined."); + } else { + path = join(getResourceDir(), "public", "src", nameWithExtension); } return fs.readFileSync(path); From 3ebfee8bd2c3871c622b30e0fbdab1a96d4faffd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 24 Jun 2025 18:49:19 +0300 Subject: [PATCH 027/360] fix(export/share): tree error in prod --- apps/server/src/share/content_renderer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index d5c3a4c9e4..167bdb7163 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -88,8 +88,11 @@ export function renderNoteContent(note: SNote) { const ancestors: string[] = []; let notePointer = note; - while (notePointer.parents[0].noteId !== subRoot.note?.noteId) { + while (notePointer.parents[0]?.noteId !== subRoot.note?.noteId) { const pointerParent = notePointer.parents[0]; + if (!pointerParent) { + break; + } ancestors.push(pointerParent.noteId); notePointer = pointerParent; } From 9abdbbbc5b2bfd9f6fbf787254eb7d32d6fb43a2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 24 Jun 2025 19:06:18 +0300 Subject: [PATCH 028/360] refactor(export/share): fix type --- apps/server/src/services/export/zip/markdown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/services/export/zip/markdown.ts b/apps/server/src/services/export/zip/markdown.ts index 1ace2051a5..827f059d68 100644 --- a/apps/server/src/services/export/zip/markdown.ts +++ b/apps/server/src/services/export/zip/markdown.ts @@ -1,5 +1,5 @@ import NoteMeta from "../../meta/note_meta" -import { ZipExportProvider } from "./abstract_provider" +import { ZipExportProvider } from "./abstract_provider.js" import mdService from "../markdown.js"; export default class MarkdownExportProvider extends ZipExportProvider { From 06de06b50115df24bb54ee1a9f6b376bdd6cff1e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 24 Jun 2025 19:21:09 +0300 Subject: [PATCH 029/360] refactor(export/share): share type for format --- apps/edit-docs/src/edit-docs.ts | 4 ++-- apps/server/src/etapi/notes.ts | 3 ++- apps/server/src/services/export/single.ts | 5 +++-- apps/server/src/services/export/zip.ts | 6 +++--- apps/server/src/services/export/zip/abstract_provider.ts | 4 +++- apps/server/src/services/export/zip/share_theme.ts | 4 ---- apps/server/src/services/meta/note_meta.ts | 3 ++- 7 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/edit-docs/src/edit-docs.ts b/apps/edit-docs/src/edit-docs.ts index db5d4be0c7..b6a04969fa 100644 --- a/apps/edit-docs/src/edit-docs.ts +++ b/apps/edit-docs/src/edit-docs.ts @@ -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/abstract_provider.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) { +async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set) { const zipFilePath = "output.zip"; try { diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts index 82280d0b91..941d095663 100644 --- a/apps/server/src/etapi/notes.ts +++ b/apps/server/src/etapi/notes.ts @@ -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) => { @@ -157,7 +158,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) => { diff --git a/apps/server/src/services/export/single.ts b/apps/server/src/services/export/single.ts index b626bf9193..2748c88506 100644 --- a/apps/server/src/services/export/single.ts +++ b/apps/server/src/services/export/single.ts @@ -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, branch: BBranch, format: "html" | "markdown", res: Response) { +function exportSingleNote(taskContext: TaskContext, 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, branch: BBranch, format: "ht taskContext.taskSucceeded(); } -export function mapByNoteType(note: BNote, content: string | Buffer, format: "html" | "markdown") { +export function mapByNoteType(note: BNote, content: string | Buffer, format: ExportFormat) { let payload, extension, mime; if (typeof content !== "string") { diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 26af3424fb..1bfc1e8423 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -19,12 +19,12 @@ 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, ZipExportProviderData } from "./zip/abstract_provider.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"; -async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "html" | "markdown" | "share", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { +async function exportToZip(taskContext: TaskContext, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { if (!["html", "markdown", "share"].includes(format)) { throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`); } @@ -432,7 +432,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h taskContext.taskSucceeded(); } -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"); diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index 6ca5fdb9a4..f777ed1cbb 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -6,6 +6,8 @@ import mimeTypes from "mime-types"; 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 template. @@ -49,7 +51,7 @@ export abstract class ZipExportProvider { abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer; abstract afterDone(rootMeta: NoteMeta): void; - mapExtension(type: string | null, mime: string, existingExtension: string, format: string) { + mapExtension(type: string | 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") { diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index f8475b0c2c..03bdb68b4d 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -65,10 +65,6 @@ export default class ShareThemeExportProvider extends ZipExportProvider { this.#saveIndex(rootMeta); } - mapExtension(_type: string | null, _mime: string, _existingExtension: string, _format: string): string | null { - return "html"; - } - #saveIndex(rootMeta: NoteMeta) { if (!this.indexMeta?.dataFileName) { return; diff --git a/apps/server/src/services/meta/note_meta.ts b/apps/server/src/services/meta/note_meta.ts index 33e7a7843a..7a7a9f4b7c 100644 --- a/apps/server/src/services/meta/note_meta.ts +++ b/apps/server/src/services/meta/note_meta.ts @@ -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) */ From fded714f18ff108f8710080a09180f47f79388a8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 24 Jun 2025 19:53:21 +0300 Subject: [PATCH 030/360] fix(export/share): use right extension for images --- apps/server/src/services/export/zip.ts | 3 ++- .../src/services/export/zip/abstract_provider.ts | 12 +++++++++++- apps/server/src/services/export/zip/share_theme.ts | 10 +++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 1bfc1e8423..10b84abce8 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -23,6 +23,7 @@ import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from 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"; async function exportToZip(taskContext: TaskContext, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { if (!["html", "markdown", "share"].includes(format)) { @@ -77,7 +78,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: Ex } } - function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record): string { + function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record): string { let fileName = baseFileName.trim(); // Crop fileName to avoid its length exceeding 30 and prevent cutting into the extension. diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts index f777ed1cbb..c9645a8437 100644 --- a/apps/server/src/services/export/zip/abstract_provider.ts +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -3,6 +3,7 @@ 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; @@ -51,7 +52,16 @@ export abstract class ZipExportProvider { abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer; abstract afterDone(rootMeta: NoteMeta): void; - mapExtension(type: string | null, mime: string, existingExtension: string, format: ExportFormat) { + /** + * 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") { diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 03bdb68b4d..06609b0313 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -1,6 +1,6 @@ import { join } from "path"; import NoteMeta, { NoteMetaFile } from "../../meta/note_meta"; -import { ZipExportProvider } from "./abstract_provider.js"; +import { ExportFormat, ZipExportProvider } from "./abstract_provider.js"; import { RESOURCE_DIR } from "../../resource_dir"; import { getResourceDir, isDev } from "../../utils"; import fs from "fs"; @@ -65,6 +65,14 @@ export default class ShareThemeExportProvider extends ZipExportProvider { 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; From 9cf7fa1997ec754b4854e4c1a5f9eacd1277710d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 24 Jun 2025 22:14:15 +0300 Subject: [PATCH 031/360] fix(export/share): use right extension for clones --- apps/server/src/services/export/zip.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 10b84abce8..58df003afe 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -126,7 +126,8 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: Ex 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, From 50f0b88eff78b17a8b630ca3d7f0c56dc3a9c928 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Fri, 10 Oct 2025 09:51:52 -0700 Subject: [PATCH 032/360] fix(search): resolve issue when using `=` operator in search --- apps/server-e2e/src/exact_search.spec.ts | 325 ++++++++++++++++++ .../expressions/note_content_fulltext.ts | 20 +- .../search/services/build_comparator.ts | 24 +- .../services/search/services/search.spec.ts | 141 +++++++- 4 files changed, 498 insertions(+), 12 deletions(-) create mode 100644 apps/server-e2e/src/exact_search.spec.ts diff --git a/apps/server-e2e/src/exact_search.spec.ts b/apps/server-e2e/src/exact_search.spec.ts new file mode 100644 index 0000000000..3d2ebbe697 --- /dev/null +++ b/apps/server-e2e/src/exact_search.spec.ts @@ -0,0 +1,325 @@ +import { test, expect } from "@playwright/test"; +import App from "./support/app"; + +const BASE_URL = "http://127.0.0.1:8082"; + +/** + * E2E tests for exact search functionality using the leading "=" operator. + * + * These tests validate the GitHub issue: + * - Searching for "pagio" returns many false positives (e.g., "page", "pages") + * - Searching for "=pagio" should return ONLY exact matches for "pagio" + */ + +test.describe("Exact Search with Leading = Operator", () => { + let csrfToken: string; + let createdNoteIds: string[] = []; + + test.beforeEach(async ({ page, context }) => { + const app = new App(page, context); + await app.goto(); + + // Get CSRF token + csrfToken = await page.evaluate(() => { + return (window as any).glob.csrfToken; + }); + + expect(csrfToken).toBeTruthy(); + + // Create test notes with specific content patterns + // Note 1: Contains exactly "pagio" in title + const note1 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Test Note with pagio", + content: "This note contains the word pagio in the content.", + type: "text" + } + }); + expect(note1.ok()).toBeTruthy(); + const note1Data = await note1.json(); + createdNoteIds.push(note1Data.note.noteId); + + // Note 2: Contains "page" (not exact match) + const note2 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Test Note with page", + content: "This note contains the word page in the content.", + type: "text" + } + }); + expect(note2.ok()).toBeTruthy(); + const note2Data = await note2.json(); + createdNoteIds.push(note2Data.note.noteId); + + // Note 3: Contains "pages" (plural, not exact match) + const note3 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Test Note with pages", + content: "This note contains the word pages in the content.", + type: "text" + } + }); + expect(note3.ok()).toBeTruthy(); + const note3Data = await note3.json(); + createdNoteIds.push(note3Data.note.noteId); + + // Note 4: Contains "homepage" (contains "page", not exact match) + const note4 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Homepage Note", + content: "This note is about homepage content.", + type: "text" + } + }); + expect(note4.ok()).toBeTruthy(); + const note4Data = await note4.json(); + createdNoteIds.push(note4Data.note.noteId); + + // Note 5: Another note with exact "pagio" in content + const note5 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Another pagio Note", + content: "This is another note with pagio content for testing exact matches.", + type: "text" + } + }); + expect(note5.ok()).toBeTruthy(); + const note5Data = await note5.json(); + createdNoteIds.push(note5Data.note.noteId); + + // Note 6: Contains "pagio" in title only + const note6 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "pagio", + content: "This note has pagio as the title.", + type: "text" + } + }); + expect(note6.ok()).toBeTruthy(); + const note6Data = await note6.json(); + createdNoteIds.push(note6Data.note.noteId); + + // Wait a bit for indexing + await page.waitForTimeout(500); + }); + + test.afterEach(async ({ page }) => { + // Clean up created notes + for (const noteId of createdNoteIds) { + try { + const taskId = `cleanup-${Math.random().toString(36).substr(2, 9)}`; + await page.request.delete(`${BASE_URL}/api/notes/${noteId}?taskId=${taskId}&last=true`, { + headers: { "x-csrf-token": csrfToken } + }); + } catch (e) { + console.error(`Failed to delete note ${noteId}:`, e); + } + } + createdNoteIds = []; + }); + + test("Quick search without = operator returns all partial matches", async ({ page }) => { + // Test the /quick-search endpoint without the = operator + const response = await page.request.get(`${BASE_URL}/api/quick-search/pag`, { + headers: { "x-csrf-token": csrfToken } + }); + + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + + // Should return multiple notes including "page", "pages", "homepage" + expect(data.searchResultNoteIds).toBeDefined(); + expect(data.searchResults).toBeDefined(); + + // Filter to only our test notes + const testResults = data.searchResults.filter((result: any) => + result.noteTitle.includes("page") || + result.noteTitle.includes("pagio") || + result.noteTitle.includes("Homepage") + ); + + // Should find at least "page", "pages", "homepage", and "pagio" notes + expect(testResults.length).toBeGreaterThanOrEqual(4); + + console.log("Quick search 'pag' found:", testResults.length, "matching notes"); + console.log("Note titles:", testResults.map((r: any) => r.noteTitle)); + }); + + test("Quick search with = operator returns only exact matches", async ({ page }) => { + // Test the /quick-search endpoint WITH the = operator + const response = await page.request.get(`${BASE_URL}/api/quick-search/=pagio`, { + headers: { "x-csrf-token": csrfToken } + }); + + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + + // Should return only notes with exact "pagio" match + expect(data.searchResultNoteIds).toBeDefined(); + expect(data.searchResults).toBeDefined(); + + // Filter to only our test notes + const testResults = data.searchResults.filter((result: any) => + createdNoteIds.includes(result.notePath.split("/").pop() || "") + ); + + console.log("Quick search '=pagio' found:", testResults.length, "matching notes"); + console.log("Note titles:", testResults.map((r: any) => r.noteTitle)); + + // Should find exactly 3 notes: "Test Note with pagio", "Another pagio Note", "pagio" + expect(testResults.length).toBe(3); + + // Verify that none of the results contain "page" or "pages" (only "pagio") + for (const result of testResults) { + const title = result.noteTitle.toLowerCase(); + const hasPageNotPagio = (title.includes("page") && !title.includes("pagio")); + expect(hasPageNotPagio).toBe(false); + } + }); + + test("Full search API without = operator returns partial matches", async ({ page }) => { + // Test the /search endpoint without the = operator + const response = await page.request.get(`${BASE_URL}/api/search/pag`, { + headers: { "x-csrf-token": csrfToken } + }); + + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + + // Should return an array of note IDs + expect(Array.isArray(data)).toBe(true); + + // Filter to only our test notes + const testNoteIds = data.filter((id: string) => createdNoteIds.includes(id)); + + console.log("Full search 'pag' found:", testNoteIds.length, "matching notes from our test set"); + + // Should find at least 4 notes + expect(testNoteIds.length).toBeGreaterThanOrEqual(4); + }); + + test("Full search API with = operator returns only exact matches", async ({ page }) => { + // Test the /search endpoint WITH the = operator + const response = await page.request.get(`${BASE_URL}/api/search/=pagio`, { + headers: { "x-csrf-token": csrfToken } + }); + + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + + // Should return an array of note IDs + expect(Array.isArray(data)).toBe(true); + + // Filter to only our test notes + const testNoteIds = data.filter((id: string) => createdNoteIds.includes(id)); + + console.log("Full search '=pagio' found:", testNoteIds.length, "matching notes from our test set"); + + // Should find exactly 3 notes with exact "pagio" match + expect(testNoteIds.length).toBe(3); + }); + + test("Exact search operator works with content search", async ({ page }) => { + // Create a note with "test" in title but different content + const noteWithTest = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Testing Content", + content: "This note contains the exact word test in content.", + type: "text" + } + }); + expect(noteWithTest.ok()).toBeTruthy(); + const noteWithTestData = await noteWithTest.json(); + const testNoteId = noteWithTestData.note.noteId; + createdNoteIds.push(testNoteId); + + // Create a note with "testing" (not exact match) + const noteWithTesting = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Testing More", + content: "This note has testing in the content.", + type: "text" + } + }); + expect(noteWithTesting.ok()).toBeTruthy(); + const noteWithTestingData = await noteWithTesting.json(); + createdNoteIds.push(noteWithTestingData.note.noteId); + + await page.waitForTimeout(500); + + // Search with exact operator + const response = await page.request.get(`${BASE_URL}/api/quick-search/=test`, { + headers: { "x-csrf-token": csrfToken } + }); + + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + + const ourTestNotes = data.searchResults.filter((result: any) => { + const noteId = result.notePath.split("/").pop(); + return noteId === testNoteId || noteId === noteWithTestingData.note.noteId; + }); + + console.log("Exact search '=test' found our test notes:", ourTestNotes.length); + console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle)); + + // Should find the note with exact "test" match, but not "testing" + // Note: This test may fail if the implementation doesn't properly handle exact matching in content + expect(ourTestNotes.length).toBeGreaterThan(0); + }); + + test("Exact search is case-insensitive", async ({ page }) => { + // Create notes with different case variations + const noteUpper = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "EXACT MATCH", + content: "This note has EXACT in uppercase.", + type: "text" + } + }); + expect(noteUpper.ok()).toBeTruthy(); + const noteUpperData = await noteUpper.json(); + createdNoteIds.push(noteUpperData.note.noteId); + + const noteLower = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "exact match", + content: "This note has exact in lowercase.", + type: "text" + } + }); + expect(noteLower.ok()).toBeTruthy(); + const noteLowerData = await noteLower.json(); + createdNoteIds.push(noteLowerData.note.noteId); + + await page.waitForTimeout(500); + + // Search with exact operator in lowercase + const response = await page.request.get(`${BASE_URL}/api/quick-search/=exact`, { + headers: { "x-csrf-token": csrfToken } + }); + + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + + const ourTestNotes = data.searchResults.filter((result: any) => { + const noteId = result.notePath.split("/").pop(); + return noteId === noteUpperData.note.noteId || noteId === noteLowerData.note.noteId; + }); + + console.log("Case-insensitive exact search found:", ourTestNotes.length, "notes"); + + // Should find both uppercase and lowercase versions + expect(ourTestNotes.length).toBe(2); + }); +}); diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts index f1e1bf95ff..967190bd92 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts @@ -89,6 +89,20 @@ class NoteContentFulltextExp extends Expression { return resultNoteSet; } + /** + * Checks if content contains the exact word (with word boundaries) + * This is case-insensitive since content and token are already normalized + */ + private containsExactWord(token: string, content: string): boolean { + // Normalize both for case-insensitive comparison + const normalizedToken = normalizeSearchText(token); + const normalizedContent = normalizeSearchText(content); + + // Split content into words and check for exact match + const words = normalizedContent.split(/\s+/); + return words.some(word => word === normalizedToken); + } + findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) { if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) { return; @@ -112,7 +126,7 @@ class NoteContentFulltextExp extends Expression { } content = this.preprocessContent(content, type, mime); - + // Apply content size validation and preprocessing const processedContent = validateAndPreprocessContent(content, noteId); if (!processedContent) { @@ -124,8 +138,8 @@ class NoteContentFulltextExp extends Expression { const [token] = this.tokens; if ( - (this.operator === "=" && token === content) || - (this.operator === "!=" && token !== content) || + (this.operator === "=" && this.containsExactWord(token, content)) || + (this.operator === "!=" && !this.containsExactWord(token, content)) || (this.operator === "*=" && content.endsWith(token)) || (this.operator === "=*" && content.startsWith(token)) || (this.operator === "*=*" && content.includes(token)) || diff --git a/apps/server/src/services/search/services/build_comparator.ts b/apps/server/src/services/search/services/build_comparator.ts index 3aebe1adb3..1f08a36d53 100644 --- a/apps/server/src/services/search/services/build_comparator.ts +++ b/apps/server/src/services/search/services/build_comparator.ts @@ -13,8 +13,28 @@ function getRegex(str: string) { type Comparator = (comparedValue: T) => (val: string) => boolean; const stringComparators: Record> = { - "=": (comparedValue) => (val) => val === comparedValue, - "!=": (comparedValue) => (val) => val !== comparedValue, + "=": (comparedValue) => (val) => { + // For the = operator, check if the value contains the exact word (word-boundary matching) + // This is case-insensitive since both values are already lowercased + if (!val) return false; + + const normalizedVal = normalizeSearchText(val); + const normalizedCompared = normalizeSearchText(comparedValue); + + // Split into words and check for exact match + const words = normalizedVal.split(/\s+/); + return words.some(word => word === normalizedCompared); + }, + "!=": (comparedValue) => (val) => { + // Negation of exact word match + if (!val) return true; + + const normalizedVal = normalizeSearchText(val); + const normalizedCompared = normalizeSearchText(comparedValue); + + const words = normalizedVal.split(/\s+/); + return !words.some(word => word === normalizedCompared); + }, ">": (comparedValue) => (val) => val > comparedValue, ">=": (comparedValue) => (val) => val >= comparedValue, "<": (comparedValue) => (val) => val < comparedValue, diff --git a/apps/server/src/services/search/services/search.spec.ts b/apps/server/src/services/search/services/search.spec.ts index d448a04b0a..c6703660b0 100644 --- a/apps/server/src/services/search/services/search.spec.ts +++ b/apps/server/src/services/search/services/search.spec.ts @@ -242,18 +242,145 @@ describe("Search", () => { const searchContext = new SearchContext(); - // Using leading = for exact title match - let searchResults = searchService.findResultsWithQuery("=Example Note", searchContext); - expect(searchResults.length).toEqual(1); + // Using leading = for exact word match - should find notes containing the exact word "example" + let searchResults = searchService.findResultsWithQuery("=example", searchContext); + expect(searchResults.length).toEqual(2); // "Example Note" and "Sample" (has label "example") expect(findNoteByTitle(searchResults, "Example Note")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "Sample")).toBeTruthy(); - // Without =, it should find all notes containing "example" + // Without =, it should find all notes containing "example" (substring match) searchResults = searchService.findResultsWithQuery("example", searchContext); - expect(searchResults.length).toEqual(3); + expect(searchResults.length).toEqual(3); // All notes // = operator should not match partial words - searchResults = searchService.findResultsWithQuery("=Example", searchContext); - expect(searchResults.length).toEqual(0); + searchResults = searchService.findResultsWithQuery("=examples", searchContext); + expect(searchResults.length).toEqual(1); // Only "Examples of Usage" + expect(findNoteByTitle(searchResults, "Examples of Usage")).toBeTruthy(); + }); + + it("leading = operator for exact match - comprehensive title tests", () => { + // Create notes with varying titles to test exact vs contains matching + rootNote + .child(note("testing")) + .child(note("testing123")) + .child(note("My testing notes")) + .child(note("123testing")) + .child(note("test")); + + const searchContext = new SearchContext(); + + // Test 1: Exact word match with leading = should find notes containing the exact word "testing" + let searchResults = searchService.findResultsWithQuery("=testing", searchContext); + expect(searchResults.length).toEqual(2); // "testing" and "My testing notes" (word boundary) + expect(findNoteByTitle(searchResults, "testing")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "My testing notes")).toBeTruthy(); + + // Test 2: Without =, it should find all notes containing "testing" (substring contains behavior) + searchResults = searchService.findResultsWithQuery("testing", searchContext); + expect(searchResults.length).toEqual(4); // All notes with "testing" substring + + // Test 3: Exact match should only find the exact composite word + searchResults = searchService.findResultsWithQuery("=testing123", searchContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "testing123")).toBeTruthy(); + + // Test 4: Exact match should only find the exact composite word + searchResults = searchService.findResultsWithQuery("=123testing", searchContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "123testing")).toBeTruthy(); + + // Test 5: Verify that "test" doesn't match "testing" with exact search + searchResults = searchService.findResultsWithQuery("=test", searchContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "test")).toBeTruthy(); + }); + + it("leading = operator with quoted phrases", () => { + rootNote + .child(note("exact phrase")) + .child(note("exact phrase match")) + .child(note("this exact phrase here")) + .child(note("phrase exact")); + + const searchContext = new SearchContext(); + + // Test 1: With = and quotes, treat as multi-word exact match (both words must match) + let searchResults = searchService.findResultsWithQuery("='exact phrase'", searchContext); + // With current implementation, this searches for notes containing both "exact" and "phrase" words + expect(searchResults.length).toEqual(4); // All notes with both words + + // Test 2: Without =, quoted phrase should find substring/contains matches + searchResults = searchService.findResultsWithQuery("'exact phrase'", searchContext); + expect(searchResults.length).toEqual(3); // All notes containing the phrase substring + expect(findNoteByTitle(searchResults, "exact phrase")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "exact phrase match")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "this exact phrase here")).toBeTruthy(); + + // Test 3: Verify word order doesn't matter with exact word matching + searchResults = searchService.findResultsWithQuery("='phrase exact'", searchContext); + expect(searchResults.length).toEqual(4); // All notes with both words + }); + + it("leading = operator case sensitivity", () => { + rootNote + .child(note("TESTING")) + .child(note("testing")) + .child(note("Testing")) + .child(note("TeStiNg")); + + const searchContext = new SearchContext(); + + // Exact match should be case-insensitive (based on lex.ts line 4: str.toLowerCase()) + let searchResults = searchService.findResultsWithQuery("=testing", searchContext); + expect(searchResults.length).toEqual(4); // All variants of "testing" + + searchResults = searchService.findResultsWithQuery("=TESTING", searchContext); + expect(searchResults.length).toEqual(4); // All variants + + searchResults = searchService.findResultsWithQuery("=Testing", searchContext); + expect(searchResults.length).toEqual(4); // All variants + + searchResults = searchService.findResultsWithQuery("=TeStiNg", searchContext); + expect(searchResults.length).toEqual(4); // All variants + }); + + it("leading = operator with special characters", () => { + rootNote + .child(note("test-note")) + .child(note("test_note")) + .child(note("test.note")) + .child(note("test note")) + .child(note("testnote")); + + const searchContext = new SearchContext(); + + // Each exact match should only find its specific variant (compound words are treated as single words) + let searchResults = searchService.findResultsWithQuery("=test-note", searchContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "test-note")).toBeTruthy(); + + searchResults = searchService.findResultsWithQuery("=test_note", searchContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "test_note")).toBeTruthy(); + + searchResults = searchService.findResultsWithQuery("=test.note", searchContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "test.note")).toBeTruthy(); + + // For phrases with spaces, use quotes to keep them together + // With word-boundary matching, this finds all notes with both words + searchResults = searchService.findResultsWithQuery("='test note'", searchContext); + expect(searchResults.length).toEqual(1); // Only "test note" has both words as separate tokens + expect(findNoteByTitle(searchResults, "test note")).toBeTruthy(); + + // Without quotes, "test note" is tokenized as two separate words + // and will match all notes containing both "test" AND "note" words + searchResults = searchService.findResultsWithQuery("=test note", searchContext); + expect(searchResults.length).toEqual(1); // Only "test note" has both as separate words + + // Without =, should find all matches containing "test" substring + searchResults = searchService.findResultsWithQuery("test", searchContext); + expect(searchResults.length).toEqual(5); }); it("fuzzy attribute search", () => { From 4fa41128407c5ce92b1a45e8fb58f8d14d148df1 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Fri, 10 Oct 2025 12:23:57 -0700 Subject: [PATCH 033/360] feat(search): also support the use of `="exact match search string"` --- apps/server-e2e/src/exact_search.spec.ts | 177 ++++++++++++++++++ .../expressions/note_content_fulltext.ts | 94 +++++++++- .../search/services/build_comparator.ts | 17 +- .../src/services/search/services/parse.ts | 11 +- .../services/search/services/search.spec.ts | 24 ++- 5 files changed, 302 insertions(+), 21 deletions(-) diff --git a/apps/server-e2e/src/exact_search.spec.ts b/apps/server-e2e/src/exact_search.spec.ts index 3d2ebbe697..1e4660e41f 100644 --- a/apps/server-e2e/src/exact_search.spec.ts +++ b/apps/server-e2e/src/exact_search.spec.ts @@ -322,4 +322,181 @@ test.describe("Exact Search with Leading = Operator", () => { // Should find both uppercase and lowercase versions expect(ourTestNotes.length).toBe(2); }); + + test("Exact phrase matching with multi-word searches", async ({ page }) => { + // Create notes with various phrase patterns + const note1 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "exact phrase", + content: "This note contains the exact phrase.", + type: "text" + } + }); + expect(note1.ok()).toBeTruthy(); + const note1Data = await note1.json(); + createdNoteIds.push(note1Data.note.noteId); + + const note2 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "exact phrase match", + content: "This note has exact phrase followed by more words.", + type: "text" + } + }); + expect(note2.ok()).toBeTruthy(); + const note2Data = await note2.json(); + createdNoteIds.push(note2Data.note.noteId); + + const note3 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "phrase exact", + content: "This note has the words in reverse order.", + type: "text" + } + }); + expect(note3.ok()).toBeTruthy(); + const note3Data = await note3.json(); + createdNoteIds.push(note3Data.note.noteId); + + const note4 = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "this exact and that phrase", + content: "Words are separated but both present.", + type: "text" + } + }); + expect(note4.ok()).toBeTruthy(); + const note4Data = await note4.json(); + createdNoteIds.push(note4Data.note.noteId); + + await page.waitForTimeout(500); + + // Search for exact phrase "exact phrase" + const response = await page.request.get(`${BASE_URL}/api/quick-search/='exact phrase'`, { + headers: { "x-csrf-token": csrfToken } + }); + + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + + const ourTestNotes = data.searchResults.filter((result: any) => { + const noteId = result.notePath.split("/").pop(); + return [note1Data.note.noteId, note2Data.note.noteId, note3Data.note.noteId, note4Data.note.noteId].includes(noteId || ""); + }); + + console.log("Exact phrase search '=\"exact phrase\"' found:", ourTestNotes.length, "notes"); + console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle)); + + // Should find only notes 1 and 2 (consecutive "exact phrase") + // Should NOT find note 3 (reversed order) or note 4 (words separated) + expect(ourTestNotes.length).toBe(2); + + const foundTitles = ourTestNotes.map((r: any) => r.noteTitle); + expect(foundTitles).toContain("exact phrase"); + expect(foundTitles).toContain("exact phrase match"); + expect(foundTitles).not.toContain("phrase exact"); + expect(foundTitles).not.toContain("this exact and that phrase"); + }); + + test("Exact phrase matching respects word order", async ({ page }) => { + // Create notes to test word order sensitivity + const noteForward = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Testing Order", + content: "This is a test sentence for verification.", + type: "text" + } + }); + expect(noteForward.ok()).toBeTruthy(); + const noteForwardData = await noteForward.json(); + createdNoteIds.push(noteForwardData.note.noteId); + + const noteReverse = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Order Testing", + content: "A sentence test is this for verification.", + type: "text" + } + }); + expect(noteReverse.ok()).toBeTruthy(); + const noteReverseData = await noteReverse.json(); + createdNoteIds.push(noteReverseData.note.noteId); + + await page.waitForTimeout(500); + + // Search for exact phrase "test sentence" + const response = await page.request.get(`${BASE_URL}/api/quick-search/='test sentence'`, { + headers: { "x-csrf-token": csrfToken } + }); + + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + + const ourTestNotes = data.searchResults.filter((result: any) => { + const noteId = result.notePath.split("/").pop(); + return noteId === noteForwardData.note.noteId || noteId === noteReverseData.note.noteId; + }); + + console.log("Exact phrase search '=\"test sentence\"' found:", ourTestNotes.length, "notes"); + console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle)); + + // Should find only the forward order note + expect(ourTestNotes.length).toBe(1); + expect(ourTestNotes[0].noteTitle).toBe("Testing Order"); + }); + + test("Multi-word exact search without quotes", async ({ page }) => { + // Test that multi-word search with = but without quotes also does exact phrase matching + const notePhrase = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Quick Test Note", + content: "A simple note for multi word testing.", + type: "text" + } + }); + expect(notePhrase.ok()).toBeTruthy(); + const notePhraseData = await notePhrase.json(); + createdNoteIds.push(notePhraseData.note.noteId); + + const noteScattered = await page.request.post(`${BASE_URL}/api/notes/root/children?target=into&targetBranchId=`, { + headers: { "x-csrf-token": csrfToken }, + data: { + title: "Word Multi Testing", + content: "Words are multi scattered in this testing example.", + type: "text" + } + }); + expect(noteScattered.ok()).toBeTruthy(); + const noteScatteredData = await noteScattered.json(); + createdNoteIds.push(noteScatteredData.note.noteId); + + await page.waitForTimeout(500); + + // Search for "=multi word" without quotes (parser tokenizes as two words) + const response = await page.request.get(`${BASE_URL}/api/quick-search/=multi word`, { + headers: { "x-csrf-token": csrfToken } + }); + + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + + const ourTestNotes = data.searchResults.filter((result: any) => { + const noteId = result.notePath.split("/").pop(); + return noteId === notePhraseData.note.noteId || noteId === noteScatteredData.note.noteId; + }); + + console.log("Multi-word exact search '=multi word' found:", ourTestNotes.length, "notes"); + console.log("Note titles:", ourTestNotes.map((r: any) => r.noteTitle)); + + // Should find only the note with consecutive "multi word" phrase + expect(ourTestNotes.length).toBe(1); + expect(ourTestNotes[0].noteTitle).toBe("Quick Test Note"); + }); }); diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts index 967190bd92..81250dda54 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts @@ -77,15 +77,43 @@ class NoteContentFulltextExp extends Expression { const resultNoteSet = new NoteSet(); + // Search through notes with content for (const row of sql.iterateRows(` SELECT noteId, type, mime, content, isProtected FROM notes JOIN blobs USING (blobId) - WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap') - AND isDeleted = 0 + WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap') + AND isDeleted = 0 AND LENGTH(content) < ${MAX_SEARCH_CONTENT_SIZE}`)) { this.findInText(row, inputNoteSet, resultNoteSet); } + // For exact match with flatText, also search notes WITHOUT content (they may have matching attributes) + if (this.flatText && (this.operator === "=" || this.operator === "!=")) { + for (const noteId of inputNoteSet.noteIdSet) { + // Skip if already found or doesn't exist + if (resultNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) { + continue; + } + + const note = becca.notes[noteId]; + const flatText = note.getFlatText(); + + // For flatText, only check attribute values (format: #name=value or ~name=value) + // Don't match against noteId, type, mime, or title which are also in flatText + let matches = false; + const phrase = this.tokens.join(" "); + const normalizedPhrase = normalizeSearchText(phrase); + const normalizedFlatText = normalizeSearchText(flatText); + + // Check if =phrase appears in flatText (indicates attribute value match) + matches = normalizedFlatText.includes(`=${normalizedPhrase}`); + + if ((this.operator === "=" && matches) || (this.operator === "!=" && !matches)) { + resultNoteSet.add(note); + } + } + } + return resultNoteSet; } @@ -103,6 +131,32 @@ class NoteContentFulltextExp extends Expression { return words.some(word => word === normalizedToken); } + /** + * Checks if content contains the exact phrase (consecutive words in order) + * This is case-insensitive since content and tokens are already normalized + */ + private containsExactPhrase(tokens: string[], content: string, checkFlatTextAttributes: boolean = false): boolean { + const normalizedTokens = tokens.map(t => normalizeSearchText(t)); + const normalizedContent = normalizeSearchText(content); + + // Join tokens with single space to form the phrase + const phrase = normalizedTokens.join(" "); + + // Check if the phrase appears as a substring (consecutive words) + if (normalizedContent.includes(phrase)) { + return true; + } + + // For flatText, also check if the phrase appears in attribute values + // Attributes in flatText appear as "#name=value" or "~name=value" + // So we need to check for "=phrase" to match attribute values + if (checkFlatTextAttributes && normalizedContent.includes(`=${phrase}`)) { + return true; + } + + return false; + } + findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) { if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) { return; @@ -137,9 +191,25 @@ class NoteContentFulltextExp extends Expression { if (this.tokens.length === 1) { const [token] = this.tokens; + let matches = false; + if (this.operator === "=") { + matches = this.containsExactWord(token, content); + // Also check flatText if enabled (includes attributes) + if (!matches && this.flatText) { + const flatText = becca.notes[noteId].getFlatText(); + matches = this.containsExactPhrase([token], flatText, true); + } + } else if (this.operator === "!=") { + matches = !this.containsExactWord(token, content); + // For negation, check flatText too + if (matches && this.flatText) { + const flatText = becca.notes[noteId].getFlatText(); + matches = !this.containsExactPhrase([token], flatText, true); + } + } + if ( - (this.operator === "=" && this.containsExactWord(token, content)) || - (this.operator === "!=" && !this.containsExactWord(token, content)) || + matches || (this.operator === "*=" && content.endsWith(token)) || (this.operator === "=*" && content.startsWith(token)) || (this.operator === "*=*" && content.includes(token)) || @@ -152,10 +222,26 @@ class NoteContentFulltextExp extends Expression { } else { // Multi-token matching with fuzzy support and phrase proximity if (this.operator === "~=" || this.operator === "~*") { + // Fuzzy phrase matching if (this.matchesWithFuzzy(content, noteId)) { resultNoteSet.add(becca.notes[noteId]); } + } else if (this.operator === "=" || this.operator === "!=") { + // Exact phrase matching for = and != + let matches = this.containsExactPhrase(this.tokens, content, false); + + // Also check flatText if enabled (includes attributes) + if (!matches && this.flatText) { + const flatText = becca.notes[noteId].getFlatText(); + matches = this.containsExactPhrase(this.tokens, flatText, true); + } + + if ((this.operator === "=" && matches) || + (this.operator === "!=" && !matches)) { + resultNoteSet.add(becca.notes[noteId]); + } } else { + // Other operators: check all tokens present (any order) const nonMatchingToken = this.tokens.find( (token) => !this.tokenMatchesContent(token, content, noteId) diff --git a/apps/server/src/services/search/services/build_comparator.ts b/apps/server/src/services/search/services/build_comparator.ts index 1f08a36d53..0f8020de39 100644 --- a/apps/server/src/services/search/services/build_comparator.ts +++ b/apps/server/src/services/search/services/build_comparator.ts @@ -14,24 +14,35 @@ type Comparator = (comparedValue: T) => (val: string) => boolean; const stringComparators: Record> = { "=": (comparedValue) => (val) => { - // For the = operator, check if the value contains the exact word (word-boundary matching) + // For the = operator, check if the value contains the exact word or phrase // This is case-insensitive since both values are already lowercased if (!val) return false; const normalizedVal = normalizeSearchText(val); const normalizedCompared = normalizeSearchText(comparedValue); - // Split into words and check for exact match + // If comparedValue has multiple words, check for exact phrase + if (normalizedCompared.includes(" ")) { + return normalizedVal.includes(normalizedCompared); + } + + // For single word, split into words and check for exact match const words = normalizedVal.split(/\s+/); return words.some(word => word === normalizedCompared); }, "!=": (comparedValue) => (val) => { - // Negation of exact word match + // Negation of exact word/phrase match if (!val) return true; const normalizedVal = normalizeSearchText(val); const normalizedCompared = normalizeSearchText(comparedValue); + // If comparedValue has multiple words, check for exact phrase + if (normalizedCompared.includes(" ")) { + return !normalizedVal.includes(normalizedCompared); + } + + // For single word, split into words and check for exact match const words = normalizedVal.split(/\s+/); return !words.some(word => word === normalizedCompared); }, diff --git a/apps/server/src/services/search/services/parse.ts b/apps/server/src/services/search/services/parse.ts index b537ee562a..03986b9ac5 100644 --- a/apps/server/src/services/search/services/parse.ts +++ b/apps/server/src/services/search/services/parse.ts @@ -38,11 +38,14 @@ function getFulltext(_tokens: TokenData[], searchContext: SearchContext, leading if (!searchContext.fastSearch) { // For exact match with "=", we need different behavior - if (leadingOperator === "=" && tokens.length === 1) { - // Exact match on title OR exact match on content + if (leadingOperator === "=" && tokens.length >= 1) { + // Exact match on title OR exact match on content OR exact match in flat text (includes attributes) + // For multi-word, join tokens with space to form exact phrase + const titleSearchValue = tokens.join(" "); return new OrExp([ - new PropertyComparisonExp(searchContext, "title", "=", tokens[0]), - new NoteContentFulltextExp("=", { tokens, flatText: false }) + new PropertyComparisonExp(searchContext, "title", "=", titleSearchValue), + new NoteContentFulltextExp("=", { tokens, flatText: false }), + new NoteContentFulltextExp("=", { tokens, flatText: true }) ]); } return new OrExp([new NoteFlatTextExp(tokens), new NoteContentFulltextExp(operator, { tokens, flatText: true })]); diff --git a/apps/server/src/services/search/services/search.spec.ts b/apps/server/src/services/search/services/search.spec.ts index c6703660b0..fc36d7d7cb 100644 --- a/apps/server/src/services/search/services/search.spec.ts +++ b/apps/server/src/services/search/services/search.spec.ts @@ -304,10 +304,13 @@ describe("Search", () => { const searchContext = new SearchContext(); - // Test 1: With = and quotes, treat as multi-word exact match (both words must match) + // Test 1: With = and quotes, treat as exact phrase match (consecutive words in order) let searchResults = searchService.findResultsWithQuery("='exact phrase'", searchContext); - // With current implementation, this searches for notes containing both "exact" and "phrase" words - expect(searchResults.length).toEqual(4); // All notes with both words + // Should match only notes containing the exact phrase "exact phrase" + expect(searchResults.length).toEqual(3); // Only notes with consecutive "exact phrase" + expect(findNoteByTitle(searchResults, "exact phrase")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "exact phrase match")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "this exact phrase here")).toBeTruthy(); // Test 2: Without =, quoted phrase should find substring/contains matches searchResults = searchService.findResultsWithQuery("'exact phrase'", searchContext); @@ -316,9 +319,10 @@ describe("Search", () => { expect(findNoteByTitle(searchResults, "exact phrase match")).toBeTruthy(); expect(findNoteByTitle(searchResults, "this exact phrase here")).toBeTruthy(); - // Test 3: Verify word order doesn't matter with exact word matching + // Test 3: Verify word order matters with exact phrase matching searchResults = searchService.findResultsWithQuery("='phrase exact'", searchContext); - expect(searchResults.length).toEqual(4); // All notes with both words + expect(searchResults.length).toEqual(1); // Only "phrase exact" matches + expect(findNoteByTitle(searchResults, "phrase exact")).toBeTruthy(); }); it("leading = operator case sensitivity", () => { @@ -368,15 +372,15 @@ describe("Search", () => { expect(findNoteByTitle(searchResults, "test.note")).toBeTruthy(); // For phrases with spaces, use quotes to keep them together - // With word-boundary matching, this finds all notes with both words + // With exact phrase matching, this finds notes with the consecutive phrase searchResults = searchService.findResultsWithQuery("='test note'", searchContext); - expect(searchResults.length).toEqual(1); // Only "test note" has both words as separate tokens + expect(searchResults.length).toEqual(1); // Only "test note" has the exact phrase expect(findNoteByTitle(searchResults, "test note")).toBeTruthy(); - // Without quotes, "test note" is tokenized as two separate words - // and will match all notes containing both "test" AND "note" words + // Without quotes, "test note" is tokenized as two separate tokens + // and will be treated as an exact phrase search with = operator searchResults = searchService.findResultsWithQuery("=test note", searchContext); - expect(searchResults.length).toEqual(1); // Only "test note" has both as separate words + expect(searchResults.length).toEqual(1); // Only "test note" has the exact phrase // Without =, should find all matches containing "test" substring searchResults = searchService.findResultsWithQuery("test", searchContext); From fb0d971e48480fe7862ffdf3db30d5a644381ed9 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Tue, 21 Oct 2025 10:12:14 -0700 Subject: [PATCH 034/360] fix(search): also support exact phrase matching such as `='test phrase'` --- .../search/expressions/note_content_fulltext.ts | 10 ++++++++-- .../src/services/search/services/build_comparator.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts index 81250dda54..cf68f6e23c 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts @@ -118,7 +118,7 @@ class NoteContentFulltextExp extends Expression { } /** - * Checks if content contains the exact word (with word boundaries) + * Checks if content contains the exact word (with word boundaries) or exact phrase * This is case-insensitive since content and token are already normalized */ private containsExactWord(token: string, content: string): boolean { @@ -126,7 +126,13 @@ class NoteContentFulltextExp extends Expression { const normalizedToken = normalizeSearchText(token); const normalizedContent = normalizeSearchText(content); - // Split content into words and check for exact match + // If token contains spaces, it's a multi-word phrase from quotes + // Check for substring match (consecutive phrase) + if (normalizedToken.includes(' ')) { + return normalizedContent.includes(normalizedToken); + } + + // For single words, split content into words and check for exact match const words = normalizedContent.split(/\s+/); return words.some(word => word === normalizedToken); } diff --git a/apps/server/src/services/search/services/build_comparator.ts b/apps/server/src/services/search/services/build_comparator.ts index 0f8020de39..c090b458f3 100644 --- a/apps/server/src/services/search/services/build_comparator.ts +++ b/apps/server/src/services/search/services/build_comparator.ts @@ -15,18 +15,19 @@ type Comparator = (comparedValue: T) => (val: string) => boolean; const stringComparators: Record> = { "=": (comparedValue) => (val) => { // For the = operator, check if the value contains the exact word or phrase - // This is case-insensitive since both values are already lowercased + // This is case-insensitive if (!val) return false; const normalizedVal = normalizeSearchText(val); const normalizedCompared = normalizeSearchText(comparedValue); - // If comparedValue has multiple words, check for exact phrase + // If comparedValue has spaces, it's a multi-word phrase + // Check for substring match (consecutive phrase) if (normalizedCompared.includes(" ")) { return normalizedVal.includes(normalizedCompared); } - // For single word, split into words and check for exact match + // For single word, split into words and check for exact word match const words = normalizedVal.split(/\s+/); return words.some(word => word === normalizedCompared); }, @@ -37,12 +38,13 @@ const stringComparators: Record> = { const normalizedVal = normalizeSearchText(val); const normalizedCompared = normalizeSearchText(comparedValue); - // If comparedValue has multiple words, check for exact phrase + // If comparedValue has spaces, it's a multi-word phrase + // Check for substring match (consecutive phrase) and negate if (normalizedCompared.includes(" ")) { return !normalizedVal.includes(normalizedCompared); } - // For single word, split into words and check for exact match + // For single word, split into words and check for exact word match, then negate const words = normalizedVal.split(/\s+/); return !words.some(word => word === normalizedCompared); }, From 8e227a61460d5dbbc0853d669878a8bd7644d468 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Tue, 21 Oct 2025 14:35:31 -0700 Subject: [PATCH 035/360] fix(search): make sure to highlight exact search results too --- apps/server/src/routes/api/search.ts | 40 ++++++++++++++++--- .../expressions/note_content_fulltext.ts | 7 ++++ .../src/services/search/services/search.ts | 38 ++++++++++++++---- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/apps/server/src/routes/api/search.ts b/apps/server/src/routes/api/search.ts index 29d75c6dca..cbd5845299 100644 --- a/apps/server/src/routes/api/search.ts +++ b/apps/server/src/routes/api/search.ts @@ -10,6 +10,8 @@ import cls from "../../services/cls.js"; import attributeFormatter from "../../services/attribute_formatter.js"; import ValidationError from "../../errors/validation_error.js"; import type SearchResult from "../../services/search/search_result.js"; +import hoistedNoteService from "../../services/hoisted_note.js"; +import beccaService from "../../becca/becca_service.js"; function searchFromNote(req: Request): SearchNoteResult { const note = becca.getNoteOrThrow(req.params.noteId); @@ -49,13 +51,41 @@ function quickSearch(req: Request) { const searchContext = new SearchContext({ fastSearch: false, includeArchivedNotes: false, - fuzzyAttributeSearch: false + includeHiddenNotes: true, + fuzzyAttributeSearch: true, + ignoreInternalAttributes: true, + ancestorNoteId: hoistedNoteService.isHoistedInHiddenSubtree() ? "root" : hoistedNoteService.getHoistedNoteId() + }); + + // Execute search with our context + const allSearchResults = searchService.findResultsWithQuery(searchString, searchContext); + const trimmed = allSearchResults.slice(0, 200); + + // Extract snippets using highlightedTokens from our context + for (const result of trimmed) { + result.contentSnippet = searchService.extractContentSnippet(result.noteId, searchContext.highlightedTokens); + result.attributeSnippet = searchService.extractAttributeSnippet(result.noteId, searchContext.highlightedTokens); + } + + // Highlight the results + searchService.highlightSearchResults(trimmed, searchContext.highlightedTokens, searchContext.ignoreInternalAttributes); + + // Map to API format + const searchResults = trimmed.map((result) => { + const { title, icon } = beccaService.getNoteTitleAndIcon(result.noteId); + return { + notePath: result.notePath, + noteTitle: title, + notePathTitle: result.notePathTitle, + highlightedNotePathTitle: result.highlightedNotePathTitle, + contentSnippet: result.contentSnippet, + highlightedContentSnippet: result.highlightedContentSnippet, + attributeSnippet: result.attributeSnippet, + highlightedAttributeSnippet: result.highlightedAttributeSnippet, + icon: icon + }; }); - // Use the same highlighting logic as autocomplete for consistency - const searchResults = searchService.searchNotesForAutocomplete(searchString, false); - - // Extract note IDs for backward compatibility const resultNoteIds = searchResults.map((result) => result.notePath.split("/").pop()).filter(Boolean) as string[]; return { diff --git a/apps/server/src/services/search/expressions/note_content_fulltext.ts b/apps/server/src/services/search/expressions/note_content_fulltext.ts index cf68f6e23c..d459bdaf72 100644 --- a/apps/server/src/services/search/expressions/note_content_fulltext.ts +++ b/apps/server/src/services/search/expressions/note_content_fulltext.ts @@ -75,6 +75,13 @@ class NoteContentFulltextExp extends Expression { return inputNoteSet; } + // Add tokens to highlightedTokens so snippet extraction knows what to look for + for (const token of this.tokens) { + if (!searchContext.highlightedTokens.includes(token)) { + searchContext.highlightedTokens.push(token); + } + } + const resultNoteSet = new NoteSet(); // Search through notes with content diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 22dbe6d9fc..5ca4bda4a1 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -500,19 +500,38 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength // Extract snippet let snippet = content.substring(snippetStart, snippetStart + maxLength); - + // If snippet contains linebreaks, limit to max 4 lines and override character limit const lines = snippet.split('\n'); if (lines.length > 4) { - snippet = lines.slice(0, 4).join('\n'); + // Find which lines contain the search tokens to ensure they're included + const normalizedLines = lines.map(line => normalizeString(line.toLowerCase())); + const normalizedTokens = searchTokens.map(token => normalizeString(token.toLowerCase())); + + // Find the first line that contains a search token + let firstMatchLine = -1; + for (let i = 0; i < normalizedLines.length; i++) { + if (normalizedTokens.some(token => normalizedLines[i].includes(token))) { + firstMatchLine = i; + break; + } + } + + if (firstMatchLine !== -1) { + // Center the 4-line window around the first match + // Try to show 1 line before and 2 lines after the match + const startLine = Math.max(0, firstMatchLine - 1); + const endLine = Math.min(lines.length, startLine + 4); + snippet = lines.slice(startLine, endLine).join('\n'); + } else { + // No match found in lines (shouldn't happen), just take first 4 + snippet = lines.slice(0, 4).join('\n'); + } // Add ellipsis if we truncated lines snippet = snippet + "..."; } else if (lines.length > 1) { - // For multi-line snippets, just limit to 4 lines (keep existing snippet) - snippet = lines.slice(0, 4).join('\n'); - if (lines.length > 4) { - snippet = snippet + "..."; - } + // For multi-line snippets that are 4 or fewer lines, keep them as-is + // No need to truncate } else { // Single line content - apply original word boundary logic // Try to start/end at word boundaries @@ -770,5 +789,8 @@ export default { searchNotesForAutocomplete, findResultsWithQuery, findFirstNoteWithQuery, - searchNotes + searchNotes, + extractContentSnippet, + extractAttributeSnippet, + highlightSearchResults }; From aa102ab3939f5c30b0afa4635fc5dc8dd0079ce2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 14:54:20 +0300 Subject: [PATCH 036/360] fix(export/share): missing templates after merge --- apps/server/src/services/export/zip/html.ts | 9 +++++++-- apps/server/src/share/content_renderer.ts | 13 ++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts index 8eb5c5d93c..259e4da67a 100644 --- a/apps/server/src/services/export/zip/html.ts +++ b/apps/server/src/services/export/zip/html.ts @@ -1,8 +1,9 @@ import type NoteMeta from "../../meta/note_meta.js"; -import { escapeHtml } from "../../utils"; -import cssContent from "@triliumnext/ckeditor5/content.css"; +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 { @@ -164,6 +165,10 @@ export default class HtmlExportProvider extends ZipExportProvider { 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 }); } diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 31c3896c6a..6a45b66e5f 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -189,15 +189,22 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) } // Render with the default view otherwise. - const templatePath = join(getResourceDir(), "share-theme", "templates", "page.ejs"); + const templatePath = getDefaultTemplatePath("page"); return ejs.render(readTemplate(templatePath), opts, { includer: (path) => { - const templatePath = join(getResourceDir(), "share-theme", "templates", `${path}.ejs`); - return { template: readTemplate(templatePath) }; + // 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`) + : `../../share-theme/templates/${template}.ejs`; +} + function readTemplate(path: string) { const cachedTemplate = templateCache.get(path); if (cachedTemplate) { From bb636128b049da91a2567f0c9186cac023044bb1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 16:02:20 +0300 Subject: [PATCH 037/360] fix(export/share): missing files and wrong meta handling --- apps/server/src/services/export/zip.ts | 20 +------------------- apps/server/src/services/export/zip/html.ts | 2 +- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 0ad0d7f22a..8733686a94 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -3,7 +3,7 @@ import dateUtils from "../date_utils.js"; import path from "path"; 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"; @@ -415,24 +415,6 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, } return; } - - const metaFileJson = JSON.stringify(metaFile, null, "\t"); - - archive.append(metaFileJson, { name: "!!!meta.json" }); - - saveNote(rootMeta, ""); - - 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); diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts index 259e4da67a..0cab8193cf 100644 --- a/apps/server/src/services/export/zip/html.ts +++ b/apps/server/src/services/export/zip/html.ts @@ -166,7 +166,7 @@ export default class HtmlExportProvider extends ZipExportProvider { } const cssFile = isDev - ? path.join(__dirname, "../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css") + ? 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 }); From 357d294f2d349f5aa5fcd8f33e3ae82d4cce9f7d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 18:25:16 +0300 Subject: [PATCH 038/360] chore(export/share): address review --- apps/server/src/services/export/zip.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 8733686a94..f7ef70bc83 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -27,14 +27,14 @@ import { NoteType } from "@triliumnext/commons"; 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' and 'markdown' allowed as export format, '${format}' given`); + 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 provider = buildProvider(); const noteIdToMeta: Record = {}; From 3660e2f12799c471099da03a7d622adbcff5bcd5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 19:00:26 +0300 Subject: [PATCH 039/360] refactor(share): store assets at /share/asset level --- apps/server/scripts/build.ts | 1 + apps/server/src/routes/assets.ts | 2 ++ apps/server/src/share/content_renderer.ts | 13 +++++-------- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/server/scripts/build.ts b/apps/server/scripts/build.ts index d2ed99ee2a..6e0c53adf1 100644 --- a/apps/server/scripts/build.ts +++ b/apps/server/scripts/build.ts @@ -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 diff --git a/apps/server/src/routes/assets.ts b/apps/server/src/routes/assets.ts index a1a2bfb63a..efcb5a3350 100644 --- a/apps/server/src/routes/assets.ts +++ b/apps/server/src/routes/assets.ts @@ -32,6 +32,7 @@ async function register(app: express.Application) { req.url = `/${assetUrlFragment}` + req.url; vite.middlewares(req, res, next); }); + app.use(`/share/assets/`, express.static(path.join(srcRoot, "../../packages/share-theme/dist"))); } else { const publicDir = path.join(resourceDir, "public"); if (!existsSync(publicDir)) { @@ -42,6 +43,7 @@ async function register(app: express.Application) { app.use(`/${assetUrlFragment}/stylesheets`, persistentCacheStatic(path.join(publicDir, "stylesheets"))); app.use(`/${assetUrlFragment}/fonts`, persistentCacheStatic(path.join(publicDir, "fonts"))); app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations"))); + app.use(`/share/assets/`, persistentCacheStatic(path.join(resourceDir, "share-theme/assets"))); app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules"))); } app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images"))); diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 6a45b66e5f..aa42ecf446 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -9,15 +9,13 @@ 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 app_path from "../services/app_path.js"; +import utils, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js"; import ejs from "ejs"; import log from "../services/log.js"; import { join } from "path"; import { readFileSync } from "fs"; const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`; -const shareAdjustedAppPath = isDev ? app_path : `../${app_path}`; const templateCache: Map = new Map(); /** @@ -99,9 +97,9 @@ export function renderNoteContent(note: SNote) { // Determine CSS to load. const cssToLoad: string[] = []; - if (!isDev && !note.isLabelTruthy("shareOmitDefaultCss")) { - cssToLoad.push(`${shareAdjustedAssetPath}/src/share.css`); - cssToLoad.push(`${shareAdjustedAssetPath}/src/boxicons.css`); + if (!note.isLabelTruthy("shareOmitDefaultCss")) { + cssToLoad.push(`assets/styles.css`); + cssToLoad.push(`assets/boxicons.css`); } for (const cssRelation of note.getRelations("shareCss")) { cssToLoad.push(`api/notes/${cssRelation.value}/download`); @@ -109,7 +107,7 @@ export function renderNoteContent(note: SNote) { // Determine JS to load. const jsToLoad: string[] = [ - `${shareAdjustedAppPath}/share.js` + "assets/scripts.js" ]; for (const jsRelation of note.getRelations("shareJs")) { jsToLoad.push(`api/notes/${jsRelation.value}/download`); @@ -147,7 +145,6 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) isEmpty, assetPath: shareAdjustedAssetPath, assetUrlFragment, - appPath: shareAdjustedAppPath, showLoginInShareTheme, t, isDev, From 4ef766748440891f1182e87a6d8568f84ab10933 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 19:09:19 +0300 Subject: [PATCH 040/360] chore(share): bring back inline mermaid rendering --- apps/client/src/share.ts | 3 --- packages/share-theme/package.json | 3 +++ packages/share-theme/src/scripts/index.ts | 2 ++ .../share-theme/src/scripts/modules}/mermaid.ts | 0 pnpm-lock.yaml | 8 ++++++++ 5 files changed, 13 insertions(+), 3 deletions(-) rename {apps/client/src/share => packages/share-theme/src/scripts/modules}/mermaid.ts (100%) diff --git a/apps/client/src/share.ts b/apps/client/src/share.ts index b438f0c0c5..d073baca11 100644 --- a/apps/client/src/share.ts +++ b/apps/client/src/share.ts @@ -32,9 +32,6 @@ async function formatCodeBlocks() { async function setupTextNote() { formatCodeBlocks(); applyMath(); - - const setupMermaid = (await import("./share/mermaid.js")).default; - setupMermaid(); } /** diff --git a/packages/share-theme/package.json b/packages/share-theme/package.json index a521b66aa0..515a6fc02d 100644 --- a/packages/share-theme/package.json +++ b/packages/share-theme/package.json @@ -21,6 +21,9 @@ "Zerebos " ], "license": "Apache-2.0", + "dependencies": { + "mermaid": "11.12.0" + }, "devDependencies": { "@digitak/esrun": "3.2.26", "@types/swagger-ui": "5.21.1", diff --git a/packages/share-theme/src/scripts/index.ts b/packages/share-theme/src/scripts/index.ts index 7b71fcb8fe..03c5c101c1 100644 --- a/packages/share-theme/src/scripts/index.ts +++ b/packages/share-theme/src/scripts/index.ts @@ -3,6 +3,7 @@ import setupExpanders from "./modules/expanders"; import setupMobileMenu from "./modules/mobile"; import setupSearch from "./modules/search"; import setupThemeSelector from "./modules/theme"; +import setupMermaid from "./modules/mermaid"; function $try unknown>(func: T, ...args: Parameters) { try { @@ -18,3 +19,4 @@ $try(setupToC); $try(setupExpanders); $try(setupMobileMenu); $try(setupSearch); +$try(setupMermaid); diff --git a/apps/client/src/share/mermaid.ts b/packages/share-theme/src/scripts/modules/mermaid.ts similarity index 100% rename from apps/client/src/share/mermaid.ts rename to packages/share-theme/src/scripts/modules/mermaid.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b3dbf2187..e2755aca26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1328,6 +1328,10 @@ importers: version: 1.2.0 packages/share-theme: + dependencies: + mermaid: + specifier: 11.12.0 + version: 11.12.0 devDependencies: '@digitak/esrun': specifier: 3.2.26 @@ -15335,6 +15339,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-editor-multi-root@47.1.0': dependencies: @@ -15831,6 +15837,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-restricted-editing@47.1.0': dependencies: From 6ae67c410cfcfbc27d1bca4bb354517c2cfbd70c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 20:52:47 +0300 Subject: [PATCH 041/360] chore(share): load Mermaid only when necessary --- packages/share-theme/src/scripts/modules/mermaid.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/share-theme/src/scripts/modules/mermaid.ts b/packages/share-theme/src/scripts/modules/mermaid.ts index 123f3816ce..78ae5a573c 100644 --- a/packages/share-theme/src/scripts/modules/mermaid.ts +++ b/packages/share-theme/src/scripts/modules/mermaid.ts @@ -1,7 +1,12 @@ -import mermaid from "mermaid"; +export default async function setupMermaid() { + const mermaidEls = document.querySelectorAll("#content pre code.language-mermaid"); + if (mermaidEls.length === 0) { + return; + } -export default function setupMermaid() { - for (const codeBlock of document.querySelectorAll("#content pre code.language-mermaid")) { + const mermaid = (await import("mermaid")).default; + + for (const codeBlock of mermaidEls) { const parentPre = codeBlock.parentElement; if (!parentPre) { continue; From b9a4e7ab117d7516734017a0ff24a0f2422a3168 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 20:52:54 +0300 Subject: [PATCH 042/360] chore(share): enable code splitting --- packages/share-theme/scripts/build.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/share-theme/scripts/build.ts b/packages/share-theme/scripts/build.ts index 586005ea58..073165f361 100644 --- a/packages/share-theme/scripts/build.ts +++ b/packages/share-theme/scripts/build.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import path from "node:path"; // import {fileURLToPath} from "node:url"; @@ -51,8 +50,9 @@ async function runBuild() { await esbuild.build({ entryPoints: entryPoints, bundle: true, + splitting: true, outdir: path.join(rootDir, "dist"), - format: "cjs", + format: "esm", target: ["chrome96"], loader: { ".png": "dataurl", From e3dd25b5918148924de4cf2e2fcf74c0573eb1c1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 21:10:31 +0300 Subject: [PATCH 043/360] chore(share): set up math --- apps/client/src/share.ts | 9 --------- apps/server/src/share/content_renderer.ts | 1 + packages/share-theme/package.json | 1 + packages/share-theme/src/scripts/index.ts | 2 ++ packages/share-theme/src/scripts/modules/math.ts | 13 +++++++++++++ packages/share-theme/src/styles/content.css | 4 ++++ pnpm-lock.yaml | 7 +++---- 7 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 packages/share-theme/src/scripts/modules/math.ts diff --git a/apps/client/src/share.ts b/apps/client/src/share.ts index d073baca11..602f7e95c6 100644 --- a/apps/client/src/share.ts +++ b/apps/client/src/share.ts @@ -9,15 +9,7 @@ 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"); @@ -31,7 +23,6 @@ async function formatCodeBlocks() { async function setupTextNote() { formatCodeBlocks(); - applyMath(); } /** diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index aa42ecf446..3b5ce0a6db 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -99,6 +99,7 @@ export function renderNoteContent(note: SNote) { const cssToLoad: string[] = []; if (!note.isLabelTruthy("shareOmitDefaultCss")) { cssToLoad.push(`assets/styles.css`); + cssToLoad.push(`assets/scripts.css`); cssToLoad.push(`assets/boxicons.css`); } for (const cssRelation of note.getRelations("shareCss")) { diff --git a/packages/share-theme/package.json b/packages/share-theme/package.json index 515a6fc02d..ecaacfb529 100644 --- a/packages/share-theme/package.json +++ b/packages/share-theme/package.json @@ -22,6 +22,7 @@ ], "license": "Apache-2.0", "dependencies": { + "katex": "0.16.25", "mermaid": "11.12.0" }, "devDependencies": { diff --git a/packages/share-theme/src/scripts/index.ts b/packages/share-theme/src/scripts/index.ts index 03c5c101c1..fbbc864d06 100644 --- a/packages/share-theme/src/scripts/index.ts +++ b/packages/share-theme/src/scripts/index.ts @@ -4,6 +4,7 @@ import setupMobileMenu from "./modules/mobile"; import setupSearch from "./modules/search"; import setupThemeSelector from "./modules/theme"; import setupMermaid from "./modules/mermaid"; +import setupMath from "./modules/math"; function $try unknown>(func: T, ...args: Parameters) { try { @@ -20,3 +21,4 @@ $try(setupExpanders); $try(setupMobileMenu); $try(setupSearch); $try(setupMermaid); +$try(setupMath); diff --git a/packages/share-theme/src/scripts/modules/math.ts b/packages/share-theme/src/scripts/modules/math.ts new file mode 100644 index 0000000000..a1c3195acc --- /dev/null +++ b/packages/share-theme/src/scripts/modules/math.ts @@ -0,0 +1,13 @@ +export default async function setupMath() { + const anyMathBlock = document.querySelector("#content .math-tex"); + if (!anyMathBlock) { + return; + } + + const renderMathInElement = (await import("katex/contrib/auto-render")).default; + await import("katex/contrib/mhchem"); + await import("katex/dist/katex.min.css"); + + renderMathInElement(document.getElementById("content")); + document.body.classList.add("math-loaded"); +} diff --git a/packages/share-theme/src/styles/content.css b/packages/share-theme/src/styles/content.css index d500888a62..e67be725a7 100644 --- a/packages/share-theme/src/styles/content.css +++ b/packages/share-theme/src/styles/content.css @@ -46,4 +46,8 @@ #content img { max-width: 100%; +} + +body:not(.math-loaded) .math-tex { + visibility: hidden; } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2755aca26..c8eab1bc01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1329,6 +1329,9 @@ importers: packages/share-theme: dependencies: + katex: + specifier: 0.16.25 + version: 0.16.25 mermaid: specifier: 11.12.0 version: 11.12.0 @@ -15085,8 +15088,6 @@ snapshots: '@ckeditor/ckeditor5-core': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-code-block@47.1.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)': dependencies: @@ -15837,8 +15838,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-restricted-editing@47.1.0': dependencies: From 21b20cf5750d9e02c6801284a2d32978f09b58ba Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 21:18:06 +0300 Subject: [PATCH 044/360] chore(share): bring back most of the logic --- apps/client/src/share.ts | 48 ------------------- packages/share-theme/src/scripts/index.ts | 34 ++++++++++++- .../share-theme/src/scripts/modules/api.ts | 18 +++++++ 3 files changed, 50 insertions(+), 50 deletions(-) create mode 100644 packages/share-theme/src/scripts/modules/api.ts diff --git a/apps/client/src/share.ts b/apps/client/src/share.ts index 602f7e95c6..579b9e422f 100644 --- a/apps/client/src/share.ts +++ b/apps/client/src/share.ts @@ -9,8 +9,6 @@ async function ensureJQuery() { (window as any).$ = $; } - - async function formatCodeBlocks() { const anyCodeBlock = document.querySelector("#content pre"); if (!anyCodeBlock) { @@ -24,49 +22,3 @@ async function formatCodeBlocks() { async function setupTextNote() { formatCodeBlocks(); } - -/** - * 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 -}); diff --git a/packages/share-theme/src/scripts/index.ts b/packages/share-theme/src/scripts/index.ts index fbbc864d06..ea08330b07 100644 --- a/packages/share-theme/src/scripts/index.ts +++ b/packages/share-theme/src/scripts/index.ts @@ -5,6 +5,7 @@ import setupSearch from "./modules/search"; import setupThemeSelector from "./modules/theme"; import setupMermaid from "./modules/mermaid"; import setupMath from "./modules/math"; +import api from "./modules/api"; function $try unknown>(func: T, ...args: Parameters) { try { @@ -15,10 +16,39 @@ function $try unknown>(func: T, ...args: Paramete } } +Object.assign(window, api); $try(setupThemeSelector); $try(setupToC); $try(setupExpanders); $try(setupMobileMenu); $try(setupSearch); -$try(setupMermaid); -$try(setupMath); + +function setupTextNote() { + $try(setupMermaid); + $try(setupMath); +} + +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; +} diff --git a/packages/share-theme/src/scripts/modules/api.ts b/packages/share-theme/src/scripts/modules/api.ts new file mode 100644 index 0000000000..adaca77d14 --- /dev/null +++ b/packages/share-theme/src/scripts/modules/api.ts @@ -0,0 +1,18 @@ +/** + * 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(); +} + +export default { + fetchNote +}; From 395f33cd5b1fddba239f1398b12a81f0261f1a17 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 22:32:42 +0300 Subject: [PATCH 045/360] chore(share): bring back boxicons --- apps/server/src/share/content_renderer.ts | 1 - packages/share-theme/package.json | 3 ++- packages/share-theme/scripts/build.ts | 2 ++ packages/share-theme/src/scripts/index.ts | 1 + packages/share-theme/src/scripts/modules/math.ts | 3 ++- pnpm-lock.yaml | 7 +++++++ 6 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 3b5ce0a6db..953feef1ca 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -100,7 +100,6 @@ export function renderNoteContent(note: SNote) { if (!note.isLabelTruthy("shareOmitDefaultCss")) { cssToLoad.push(`assets/styles.css`); cssToLoad.push(`assets/scripts.css`); - cssToLoad.push(`assets/boxicons.css`); } for (const cssRelation of note.getRelations("shareCss")) { cssToLoad.push(`api/notes/${cssRelation.value}/download`); diff --git a/packages/share-theme/package.json b/packages/share-theme/package.json index ecaacfb529..68916cc3ec 100644 --- a/packages/share-theme/package.json +++ b/packages/share-theme/package.json @@ -23,7 +23,8 @@ "license": "Apache-2.0", "dependencies": { "katex": "0.16.25", - "mermaid": "11.12.0" + "mermaid": "11.12.0", + "boxicons": "2.1.4" }, "devDependencies": { "@digitak/esrun": "3.2.26", diff --git a/packages/share-theme/scripts/build.ts b/packages/share-theme/scripts/build.ts index 073165f361..5129354935 100644 --- a/packages/share-theme/scripts/build.ts +++ b/packages/share-theme/scripts/build.ts @@ -60,6 +60,8 @@ async function runBuild() { ".woff": "dataurl", ".woff2": "dataurl", ".ttf": "dataurl", + ".eot": "empty", + ".svg": "empty", ".html": "text", ".css": "css" }, diff --git a/packages/share-theme/src/scripts/index.ts b/packages/share-theme/src/scripts/index.ts index ea08330b07..f076a4bcc4 100644 --- a/packages/share-theme/src/scripts/index.ts +++ b/packages/share-theme/src/scripts/index.ts @@ -6,6 +6,7 @@ import setupThemeSelector from "./modules/theme"; import setupMermaid from "./modules/mermaid"; import setupMath from "./modules/math"; import api from "./modules/api"; +import "boxicons/css/boxicons.min.css"; function $try unknown>(func: T, ...args: Parameters) { try { diff --git a/packages/share-theme/src/scripts/modules/math.ts b/packages/share-theme/src/scripts/modules/math.ts index a1c3195acc..bdba2ded95 100644 --- a/packages/share-theme/src/scripts/modules/math.ts +++ b/packages/share-theme/src/scripts/modules/math.ts @@ -1,3 +1,5 @@ +import "katex/dist/katex.min.css"; + export default async function setupMath() { const anyMathBlock = document.querySelector("#content .math-tex"); if (!anyMathBlock) { @@ -6,7 +8,6 @@ export default async function setupMath() { const renderMathInElement = (await import("katex/contrib/auto-render")).default; await import("katex/contrib/mhchem"); - await import("katex/dist/katex.min.css"); renderMathInElement(document.getElementById("content")); document.body.classList.add("math-loaded"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8eab1bc01..d5cd6af3dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1329,6 +1329,9 @@ importers: packages/share-theme: dependencies: + boxicons: + specifier: 2.1.4 + version: 2.1.4 katex: specifier: 0.16.25 version: 0.16.25 @@ -15088,6 +15091,8 @@ snapshots: '@ckeditor/ckeditor5-core': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-code-block@47.1.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)': dependencies: @@ -15838,6 +15843,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-restricted-editing@47.1.0': dependencies: From 0c399a676a0ea86de6bb8f905d8cb8d8aa071940 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 24 Oct 2025 23:28:42 +0300 Subject: [PATCH 046/360] chore(share): fix typecheck issue --- packages/share-theme/src/types.d.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/share-theme/src/types.d.ts diff --git a/packages/share-theme/src/types.d.ts b/packages/share-theme/src/types.d.ts new file mode 100644 index 0000000000..3fa19bd499 --- /dev/null +++ b/packages/share-theme/src/types.d.ts @@ -0,0 +1,5 @@ +declare module "katex/contrib/auto-render" { + export default function renderMathInElement(elem: HTMLElement, options?: {}) +} + +declare module "katex/contrib/mhchem" {} From 1a68bdfe026646f14aa999f76cd148088e63d685 Mon Sep 17 00:00:00 2001 From: Ryan Keane Date: Sat, 25 Oct 2025 01:00:14 -0700 Subject: [PATCH 047/360] fix(windows script): add -command flag I don't know why if I replace old, system builtin powershell executable with powershell 7, scripts fail with this error: The argument 'Set-Item -Path ...' is not recognized as the name of a script file. Check the spelling of the name, or if a path was included, verify that the path is correct and try again. Adding -Command at the end of flags just fixed it. Signed-off-by: Ryan Keane --- apps/desktop/electron-forge/trilium-no-cert-check.bat | 2 +- apps/desktop/electron-forge/trilium-portable.bat | 2 +- apps/desktop/electron-forge/trilium-safe-mode.bat | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/electron-forge/trilium-no-cert-check.bat b/apps/desktop/electron-forge/trilium-no-cert-check.bat index cfc345c42c..d3405b4979 100644 --- a/apps/desktop/electron-forge/trilium-no-cert-check.bat +++ b/apps/desktop/electron-forge/trilium-no-cert-check.bat @@ -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 diff --git a/apps/desktop/electron-forge/trilium-portable.bat b/apps/desktop/electron-forge/trilium-portable.bat index b6eeb853f5..5b71c9cf2a 100644 --- a/apps/desktop/electron-forge/trilium-portable.bat +++ b/apps/desktop/electron-forge/trilium-portable.bat @@ -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 diff --git a/apps/desktop/electron-forge/trilium-safe-mode.bat b/apps/desktop/electron-forge/trilium-safe-mode.bat index 7e0fafa991..e112896f75 100644 --- a/apps/desktop/electron-forge/trilium-safe-mode.bat +++ b/apps/desktop/electron-forge/trilium-safe-mode.bat @@ -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 From 1182592fc58e6ba2d5375262cb95920e03fdcfaf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 26 Oct 2025 10:07:42 +0200 Subject: [PATCH 048/360] chore(share): fix another typecheck issue --- packages/share-theme/src/scripts/modules/math.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/share-theme/src/scripts/modules/math.ts b/packages/share-theme/src/scripts/modules/math.ts index bdba2ded95..3893dbeeda 100644 --- a/packages/share-theme/src/scripts/modules/math.ts +++ b/packages/share-theme/src/scripts/modules/math.ts @@ -9,6 +9,8 @@ export default async function setupMath() { const renderMathInElement = (await import("katex/contrib/auto-render")).default; await import("katex/contrib/mhchem"); - renderMathInElement(document.getElementById("content")); + const contentEl = document.getElementById("content"); + if (!contentEl) return; + renderMathInElement(contentEl); document.body.classList.add("math-loaded"); } From 212956201a3c1dd56cb4a9b15835104b1df61917 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 26 Oct 2025 11:02:19 +0200 Subject: [PATCH 049/360] chore(export/share): export full share script & styles --- apps/server/src/routes/assets.ts | 13 +++++++++++-- apps/server/src/services/export/zip/share_theme.ts | 12 +++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/server/src/routes/assets.ts b/apps/server/src/routes/assets.ts index efcb5a3350..1e2ff30b14 100644 --- a/apps/server/src/routes/assets.ts +++ b/apps/server/src/routes/assets.ts @@ -32,7 +32,6 @@ async function register(app: express.Application) { req.url = `/${assetUrlFragment}` + req.url; vite.middlewares(req, res, next); }); - app.use(`/share/assets/`, express.static(path.join(srcRoot, "../../packages/share-theme/dist"))); } else { const publicDir = path.join(resourceDir, "public"); if (!existsSync(publicDir)) { @@ -43,9 +42,9 @@ async function register(app: express.Application) { app.use(`/${assetUrlFragment}/stylesheets`, persistentCacheStatic(path.join(publicDir, "stylesheets"))); app.use(`/${assetUrlFragment}/fonts`, persistentCacheStatic(path.join(publicDir, "fonts"))); app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations"))); - app.use(`/share/assets/`, persistentCacheStatic(path.join(resourceDir, "share-theme/assets"))); 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"))); @@ -53,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 }; diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 06609b0313..3439fe3e47 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -3,10 +3,13 @@ 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 from "fs"; +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 { @@ -14,6 +17,7 @@ export default class ShareThemeExportProvider extends ZipExportProvider { private indexMeta: NoteMeta | null = null; prepareMeta(metaFile: NoteMetaFile): void { + const assets = [ "style.css", "script.js", @@ -26,6 +30,10 @@ export default class ShareThemeExportProvider extends ZipExportProvider { "icon-color.svg" ]; + for (const file of readdirSync(shareThemeAssetDir)) { + assets.push(`assets/${file}`); + } + for (const asset of assets) { const assetMeta = { noImport: true, @@ -107,6 +115,8 @@ 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 { From f4468706ef56f7d5affb799d847731ceb8a07b4d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 26 Oct 2025 11:07:08 +0200 Subject: [PATCH 050/360] fix(export/share): asset path for styles and scripts --- apps/server/src/services/export/zip/share_theme.ts | 2 -- apps/server/src/share/content_renderer.ts | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index 3439fe3e47..cb04b847a8 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -19,8 +19,6 @@ export default class ShareThemeExportProvider extends ZipExportProvider { prepareMeta(metaFile: NoteMetaFile): void { const assets = [ - "style.css", - "script.js", "boxicons.css", "boxicons.eot", "boxicons.woff2", diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 953feef1ca..73234a79fc 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -70,11 +70,10 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath subRoot, rootNoteId: parentBranch.noteId, cssToLoad: [ - `${basePath}style.css`, - `${basePath}boxicons.css` + `${basePath}assets/styles.css`, ], jsToLoad: [ - `${basePath}script.js` + `${basePath}assets/scripts.js` ], logoUrl: `${basePath}icon-color.svg`, ancestors From 055fcb7b2a8072181b480e8ebef42f30ce02c513 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 26 Oct 2025 11:34:09 +0200 Subject: [PATCH 051/360] fix(export/share): handling of fonts --- apps/server/src/services/export/zip/share_theme.ts | 6 ------ apps/server/src/share/content_renderer.ts | 1 + packages/share-theme/scripts/build.ts | 6 +++--- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index cb04b847a8..c33951c034 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -19,12 +19,6 @@ export default class ShareThemeExportProvider extends ZipExportProvider { prepareMeta(metaFile: NoteMetaFile): void { const assets = [ - "boxicons.css", - "boxicons.eot", - "boxicons.woff2", - "boxicons.woff", - "boxicons.ttf", - "boxicons.svg", "icon-color.svg" ]; diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 73234a79fc..4acde9b861 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -71,6 +71,7 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath rootNoteId: parentBranch.noteId, cssToLoad: [ `${basePath}assets/styles.css`, + `${basePath}assets/scripts.css`, ], jsToLoad: [ `${basePath}assets/scripts.js` diff --git a/packages/share-theme/scripts/build.ts b/packages/share-theme/scripts/build.ts index 5129354935..13b2ac493c 100644 --- a/packages/share-theme/scripts/build.ts +++ b/packages/share-theme/scripts/build.ts @@ -57,9 +57,9 @@ async function runBuild() { loader: { ".png": "dataurl", ".gif": "dataurl", - ".woff": "dataurl", - ".woff2": "dataurl", - ".ttf": "dataurl", + ".woff": "file", + ".woff2": "file", + ".ttf": "file", ".eot": "empty", ".svg": "empty", ".html": "text", From ba26c478d6aa68f0107592ce7a847864a239b029 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 26 Oct 2025 12:15:35 +0200 Subject: [PATCH 052/360] fix(export/share): assets incorrectly rewritten --- apps/server/src/services/export/zip/share_theme.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts index c33951c034..e33390735e 100644 --- a/apps/server/src/services/export/zip/share_theme.ts +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -52,7 +52,10 @@ export default class ShareThemeExportProvider extends ZipExportProvider { 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, "href=\"#root/$1\""); + 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); } } From 52a6f2597e4a74b9916d8e17dad494525fe16f6b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 26 Oct 2025 21:38:16 +0200 Subject: [PATCH 053/360] fix(share): template directory in production --- apps/server/src/share/content_renderer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 4acde9b861..611930f04e 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -9,7 +9,7 @@ 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, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js"; +import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js"; import ejs from "ejs"; import log from "../services/log.js"; import { join } from "path"; @@ -199,7 +199,7 @@ 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`) - : `../../share-theme/templates/${template}.ejs`; + : join(getResourceDir(), `share-theme/templates/${template}.ejs`); } function readTemplate(path: string) { From 3661733f070cda154d7d96da4d58b4260d93bfb1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 26 Oct 2025 22:00:11 +0200 Subject: [PATCH 054/360] chore(server): remove duplicate math handling --- apps/server/src/share/content_renderer.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 611930f04e..2fd6d55a96 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -301,19 +301,6 @@ function renderText(result: Result, note: SNote | BNote) { result.content = document.innerHTML ?? ""; - if (result.content.includes(``)) { - result.header += ` - - - - -`; - } - if (note.hasLabel("shareIndex")) { renderIndex(result); } From 2197fae700839e4fe78c23503c21102d2335cc18 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 01:55:23 +0000 Subject: [PATCH 055/360] chore(deps): update dependency @types/turndown to v5.0.6 --- apps/server/package.json | 2 +- pnpm-lock.yaml | 24 +++++++++++------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index cf021cb6ed..1380183ada 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -61,7 +61,7 @@ "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67292f16fb..6836ee417e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -559,8 +559,8 @@ importers: specifier: 0.2.6 version: 0.2.6 '@types/turndown': - specifier: 5.0.5 - version: 5.0.5 + specifier: 5.0.6 + version: 5.0.6 '@types/ws': specifier: 8.18.1 version: 8.18.1 @@ -5188,8 +5188,8 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/turndown@5.0.5': - resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -15004,8 +15004,6 @@ snapshots: '@ckeditor/ckeditor5-core': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-code-block@47.1.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)': dependencies: @@ -15067,6 +15065,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 '@ckeditor/ckeditor5-watchdog': 47.1.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-dev-build-tools@43.1.0(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.3)': dependencies: @@ -15231,8 +15231,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-editor-classic@47.1.0': dependencies: @@ -15601,6 +15599,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 '@ckeditor/ckeditor5-widget': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-mention@47.1.0(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)': dependencies: @@ -15754,6 +15754,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-restricted-editing@47.1.0': dependencies: @@ -15840,8 +15842,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-special-characters@47.1.0': dependencies: @@ -15953,8 +15953,6 @@ snapshots: '@ckeditor/ckeditor5-icons': 47.1.0 '@ckeditor/ckeditor5-ui': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-upload@47.1.0': dependencies: @@ -19765,7 +19763,7 @@ snapshots: '@types/trusted-types@2.0.7': optional: true - '@types/turndown@5.0.5': {} + '@types/turndown@5.0.6': {} '@types/unist@3.0.3': {} From 7dd517d8f7b66a268423c72639d33a737eab3a85 Mon Sep 17 00:00:00 2001 From: SiriusXT <1160925501@qq.com> Date: Mon, 27 Oct 2025 14:42:22 +0800 Subject: [PATCH 056/360] fix (empty tab): recent notes not showing when creating a empty tab --- apps/client/src/widgets/dialogs/popup_editor.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/client/src/widgets/dialogs/popup_editor.ts b/apps/client/src/widgets/dialogs/popup_editor.ts index 1deb584d93..fed1055155 100644 --- a/apps/client/src/widgets/dialogs/popup_editor.ts +++ b/apps/client/src/widgets/dialogs/popup_editor.ts @@ -150,6 +150,11 @@ export default class PopupEditorDialog extends Container { } handleEventInChildren(name: T, data: EventData): Promise | null { + // Avoid not showing recent notes when creating a new empty tab. + if (name === 'activeContextChanged' && "noteContext" in data && data.noteContext.isEmpty()) { + return Promise.resolve(); + } + // Avoid events related to the current tab interfere with our popup. if (["noteSwitched", "noteSwitchedAndActivated", "exportAsPdf", "printActiveNote"].includes(name)) { return Promise.resolve(); From 56019e544981d3aa58eb8629e6a7ad9f6c4246c6 Mon Sep 17 00:00:00 2001 From: SiriusXT <1160925501@qq.com> Date: Mon, 27 Oct 2025 16:59:28 +0800 Subject: [PATCH 057/360] fix (empty tab): recent notes not showing when creating a empty tab --- apps/client/src/widgets/dialogs/popup_editor.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/dialogs/popup_editor.ts b/apps/client/src/widgets/dialogs/popup_editor.ts index fed1055155..f08719a937 100644 --- a/apps/client/src/widgets/dialogs/popup_editor.ts +++ b/apps/client/src/widgets/dialogs/popup_editor.ts @@ -150,13 +150,13 @@ export default class PopupEditorDialog extends Container { } handleEventInChildren(name: T, data: EventData): Promise | null { - // Avoid not showing recent notes when creating a new empty tab. - if (name === 'activeContextChanged' && "noteContext" in data && data.noteContext.isEmpty()) { + // Avoid events related to the current tab interfere with our popup. + if (["noteSwitched", "noteSwitchedAndActivated", "exportAsPdf", "printActiveNote"].includes(name)) { return Promise.resolve(); } - // Avoid events related to the current tab interfere with our popup. - if (["noteSwitched", "noteSwitchedAndActivated", "exportAsPdf", "printActiveNote"].includes(name)) { + // Avoid not showing recent notes when creating a new empty tab. + if ("noteContext" in data && data.noteContext.ntxId !== "_popup-editor") { return Promise.resolve(); } From f20078f3b0a8f406a7a53e67e177c6fdeeef3e0a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 27 Oct 2025 16:17:51 +0200 Subject: [PATCH 058/360] fix(print): some images not loading --- apps/client/src/print.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/client/src/print.tsx b/apps/client/src/print.tsx index de11d581a6..3dbdf1de07 100644 --- a/apps/client/src/print.tsx +++ b/apps/client/src/print.tsx @@ -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(resolve => { + img.addEventListener("load", () => resolve(), { once: true }); + img.addEventListener("error", () => resolve(), { once: true }); + }); + }) + ); } load().then(() => requestAnimationFrame(onReady)) From a224b774d3dd92ac7dca05ab04bea08a8a286093 Mon Sep 17 00:00:00 2001 From: marc hooijschuur Date: Sun, 26 Oct 2025 14:41:03 +0100 Subject: [PATCH 059/360] Translated using Weblate (Dutch) Currently translated at 2.9% (48 of 1621 strings) Translation: Trilium Notes/Client Translate-URL: https://hosted.weblate.org/projects/trilium/client/nl/ --- apps/client/src/translations/nl/translation.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/client/src/translations/nl/translation.json b/apps/client/src/translations/nl/translation.json index d07df69e53..de5cfb6c7b 100644 --- a/apps/client/src/translations/nl/translation.json +++ b/apps/client/src/translations/nl/translation.json @@ -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": { From cd3e025fdcfea05ee756efd9e9185bc2d603c6ae Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Sun, 26 Oct 2025 22:03:34 +0100 Subject: [PATCH 060/360] Update translation files Updated by "Cleanup translation files" add-on in Weblate. Translation: Trilium Notes/Website Translate-URL: https://hosted.weblate.org/projects/trilium/website/ --- apps/website/public/translations/es/translation.json | 3 +-- apps/website/public/translations/fr/translation.json | 3 +-- apps/website/public/translations/it/translation.json | 3 +-- apps/website/public/translations/ja/translation.json | 3 +-- apps/website/public/translations/pl/translation.json | 3 +-- apps/website/public/translations/ro/translation.json | 9 ++++----- .../website/public/translations/zh-Hant/translation.json | 3 +-- 7 files changed, 10 insertions(+), 17 deletions(-) diff --git a/apps/website/public/translations/es/translation.json b/apps/website/public/translations/es/translation.json index 2f7316d5e7..cba0aba90e 100644 --- a/apps/website/public/translations/es/translation.json +++ b/apps/website/public/translations/es/translation.json @@ -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 ", diff --git a/apps/website/public/translations/fr/translation.json b/apps/website/public/translations/fr/translation.json index 0b7fe98bfc..b464b2d71b 100644 --- a/apps/website/public/translations/fr/translation.json +++ b/apps/website/public/translations/fr/translation.json @@ -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", diff --git a/apps/website/public/translations/it/translation.json b/apps/website/public/translations/it/translation.json index 2ffc45a94d..d8634a78c3 100644 --- a/apps/website/public/translations/it/translation.json +++ b/apps/website/public/translations/it/translation.json @@ -95,8 +95,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 ", diff --git a/apps/website/public/translations/ja/translation.json b/apps/website/public/translations/ja/translation.json index 96115d80ea..eae21a8bfb 100644 --- a/apps/website/public/translations/ja/translation.json +++ b/apps/website/public/translations/ja/translation.json @@ -95,8 +95,7 @@ "get_started": "はじめる" }, "components": { - "link_learn_more": "さらに詳しく...", - "list_with_screenshot_alt": "選択中の機能のスクリーンショット" + "link_learn_more": "さらに詳しく..." }, "download_now": { "text": "今すぐダウンロード ", diff --git a/apps/website/public/translations/pl/translation.json b/apps/website/public/translations/pl/translation.json index 4d4c5df02a..b4ddd8e139 100644 --- a/apps/website/public/translations/pl/translation.json +++ b/apps/website/public/translations/pl/translation.json @@ -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 ", diff --git a/apps/website/public/translations/ro/translation.json b/apps/website/public/translations/ro/translation.json index ca836e1a8d..5cb462254b 100644 --- a/apps/website/public/translations/ro/translation.json +++ b/apps/website/public/translations/ro/translation.json @@ -95,8 +95,7 @@ "get_started": "Începe" }, "components": { - "link_learn_more": "Mai multe detalii...", - "list_with_screenshot_alt": "Captură de ecran a funcției selectate" + "link_learn_more": "Mai multe detalii..." }, "download_now": { "text": "Descărcați acum ", @@ -107,9 +106,9 @@ "more_platforms": "Mai multe platforme și instalarea server-ului" }, "header": { - "get-started": "Primii pași", - "documentation": "Documentație", - "support-us": "Sprijină-ne" + "get-started": "Primii pași", + "documentation": "Documentație", + "support-us": "Sprijină-ne" }, "footer": { "copyright_and_the": " și ", diff --git a/apps/website/public/translations/zh-Hant/translation.json b/apps/website/public/translations/zh-Hant/translation.json index 580c54fba8..a941e5686e 100644 --- a/apps/website/public/translations/zh-Hant/translation.json +++ b/apps/website/public/translations/zh-Hant/translation.json @@ -95,8 +95,7 @@ "get_started": "上手指南" }, "components": { - "link_learn_more": "了解更多…", - "list_with_screenshot_alt": "已選擇功能的螢幕截圖" + "link_learn_more": "了解更多…" }, "download_now": { "text": "馬上下載 ", From 6966efd37426aa05f3f5d9f72dce7eea3a9fb569 Mon Sep 17 00:00:00 2001 From: green Date: Mon, 27 Oct 2025 05:29:00 +0100 Subject: [PATCH 061/360] Translated using Weblate (Japanese) Currently translated at 98.0% (149 of 152 strings) Translation: Trilium Notes/Website Translate-URL: https://hosted.weblate.org/projects/trilium/website/ja/ --- apps/website/public/translations/ja/translation.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/website/public/translations/ja/translation.json b/apps/website/public/translations/ja/translation.json index eae21a8bfb..3507db1213 100644 --- a/apps/website/public/translations/ja/translation.json +++ b/apps/website/public/translations/ja/translation.json @@ -51,7 +51,8 @@ "mermaid_description": "Mermaid 構文を使用して、フローチャート、クラス図、シーケンス図、ガント チャートなどの図を作成します。", "mindmap_title": "マインドマップ", "mindmap_description": "考えを視覚的に整理したり、ブレインストーミング セッションを行ったりします。", - "others_list": "その他: <0>ノートマップ、<1>リレーションマップ、<2>保存された検索、<3>レンダリングノート、<4>Web ビュー。" + "others_list": "その他: <0>ノートマップ、<1>リレーションマップ、<2>保存された検索、<3>レンダリングノート、<4>Web ビュー。", + "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": "よくある質問", From f4402a6d810a1f121d2719c27c11ba5e5eba8c43 Mon Sep 17 00:00:00 2001 From: green Date: Mon, 27 Oct 2025 08:52:19 +0100 Subject: [PATCH 062/360] Translated using Weblate (Japanese) Currently translated at 100.0% (152 of 152 strings) Translation: Trilium Notes/Website Translate-URL: https://hosted.weblate.org/projects/trilium/website/ja/ --- apps/website/public/translations/ja/translation.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/website/public/translations/ja/translation.json b/apps/website/public/translations/ja/translation.json index 3507db1213..d7cb3f599d 100644 --- a/apps/website/public/translations/ja/translation.json +++ b/apps/website/public/translations/ja/translation.json @@ -191,5 +191,10 @@ "description": "Trilium Notesは、アクセスと管理を容易にする有料サービス PikaPods でホストされています。Trilium チームとは直接関係ありません。", "download_pikapod": "PikaPods にセットアップする", "download_triliumcc": "または、trilium.cc を参照してください" + }, + "header": { + "get-started": "はじめる", + "documentation": "ドキュメント", + "support-us": "サポート" } } From f3f7e5900b5bbf53dfd0c6ffcbcd1821b9010e08 Mon Sep 17 00:00:00 2001 From: Giovi Date: Mon, 27 Oct 2025 09:29:13 +0100 Subject: [PATCH 063/360] Translated using Weblate (Italian) Currently translated at 100.0% (152 of 152 strings) Translation: Trilium Notes/Website Translate-URL: https://hosted.weblate.org/projects/trilium/website/it/ --- .../website/public/translations/it/translation.json | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/website/public/translations/it/translation.json b/apps/website/public/translations/it/translation.json index d8634a78c3..03792e5ed4 100644 --- a/apps/website/public/translations/it/translation.json +++ b/apps/website/public/translations/it/translation.json @@ -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, <1>mappa delle relazioni, <2>ricerche salvate, <3>renderizza nota e <4>visualizzazioni web." + "others_list": "e altri: <0>mappa delle note, <1>mappa delle relazioni, <2>ricerche salvate, <3>renderizza nota e <4>visualizzazioni web.", + "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", @@ -187,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" } } From 40f5abd6e36b1350a65248bbe9138c7feb3482e7 Mon Sep 17 00:00:00 2001 From: Francis C Date: Mon, 27 Oct 2025 09:47:34 +0100 Subject: [PATCH 064/360] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (152 of 152 strings) Translation: Trilium Notes/Website Translate-URL: https://hosted.weblate.org/projects/trilium/website/zh_Hant/ --- .../public/translations/zh-Hant/translation.json | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/website/public/translations/zh-Hant/translation.json b/apps/website/public/translations/zh-Hant/translation.json index a941e5686e..80929ce944 100644 --- a/apps/website/public/translations/zh-Hant/translation.json +++ b/apps/website/public/translations/zh-Hant/translation.json @@ -51,7 +51,8 @@ "mermaid_description": "使用 Mermaid 語法繪製流程圖、類別圖與序列圖、甘特圖等多種圖表。", "mindmap_title": "心智圖", "mindmap_description": "以視覺方式整理思緒,或進行腦力激盪。", - "others_list": "及其他項目:<0>筆記地圖、<1>關聯地圖、<2>儲存搜尋、<3>渲染筆記,以及<4>網頁檢視。" + "others_list": "及其他項目:<0>筆記地圖、<1>關聯地圖、<2>儲存搜尋、<3>渲染筆記,以及<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": "常見問題", @@ -187,5 +191,10 @@ "description": "Trilium Notes 託管於 PikaPods,此為付費服務,提供便捷存取與管理功能。與 Trilium 團隊無直接關聯。", "download_pikapod": "在 PikaPods 上設定", "download_triliumcc": "或參見 trilium.cc" + }, + "header": { + "get-started": "上手指南", + "documentation": "文件", + "support-us": "支持我們" } } From c4c8fe23a9999f29685d58729b01334965130120 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 27 Oct 2025 16:29:44 +0200 Subject: [PATCH 065/360] fix(website): pages not prerendered --- apps/website/src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/website/src/index.tsx b/apps/website/src/index.tsx index 8cbea3d0ec..92455792a2 100644 --- a/apps/website/src/index.tsx +++ b/apps/website/src/index.tsx @@ -69,7 +69,7 @@ export function LocaleProvider({ children }) { return ( - {loaded && children} + {children} ); } From 86aaa97809b90f72ffabadad79cdd103f653fae1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 27 Oct 2025 16:30:03 +0200 Subject: [PATCH 066/360] fix(website): language-specific pages not properly determined --- apps/website/src/i18n.spec.ts | 1 + apps/website/src/i18n.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/website/src/i18n.spec.ts b/apps/website/src/i18n.spec.ts index eab06ba5af..fcc8d790aa 100644 --- a/apps/website/src/i18n.spec.ts +++ b/apps/website/src/i18n.spec.ts @@ -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"); }); }); diff --git a/apps/website/src/i18n.ts b/apps/website/src/i18n.ts index 4352452171..10608e5716 100644 --- a/apps/website/src/i18n.ts +++ b/apps/website/src/i18n.ts @@ -35,7 +35,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("/"); From dbfa94a9ee45a7fef43cd97c90df35f85d7bad34 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 27 Oct 2025 16:35:26 +0200 Subject: [PATCH 067/360] fix(website): missing suspense --- apps/website/src/components/Header.tsx | 5 +++- apps/website/src/index.tsx | 37 ++++++++++++++------------ apps/website/src/pages/Home/index.tsx | 5 +++- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/apps/website/src/components/Header.tsx b/apps/website/src/components/Header.tsx index 352e52fba7..ce28ffd49a 100644 --- a/apps/website/src/components/Header.tsx +++ b/apps/website/src/components/Header.tsx @@ -11,6 +11,7 @@ import menuIcon from "../assets/boxicons/bx-menu.svg?raw"; import { LocaleContext } from ".."; import { useTranslation } from "react-i18next"; import { swapLocaleInUrl } from "../i18n"; +import { Suspense } from "preact/compat"; interface HeaderLink { url: string; @@ -78,7 +79,9 @@ export function Header(props: {repoStargazersCount: number}) { /> - + Loading...}> + + diff --git a/apps/website/src/index.tsx b/apps/website/src/index.tsx index 92455792a2..1aafe83678 100644 --- a/apps/website/src/index.tsx +++ b/apps/website/src/index.tsx @@ -13,30 +13,33 @@ 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 { Suspense } from 'preact/compat'; export const LocaleContext = createContext('en'); export function App(props: {repoStargazersCount: number}) { return ( - - -
    -
    - - - - + + + Loading...}> +
    +
    + + + + - - - + + + - - -
    -
    - - + + +
    +
    + + + ); } diff --git a/apps/website/src/pages/Home/index.tsx b/apps/website/src/pages/Home/index.tsx index b26d436213..02b97bbdcc 100644 --- a/apps/website/src/pages/Home/index.tsx +++ b/apps/website/src/pages/Home/index.tsx @@ -33,6 +33,7 @@ import presentationIcon from "../../assets/boxicons/bx-slideshow.svg?raw"; import { getPlatform } from '../../download-helper.js'; import { useEffect, useState } from 'preact/hooks'; import { Trans, useTranslation } from 'react-i18next'; +import { Suspense } from 'preact/compat'; export function Home() { usePageTitle(""); @@ -79,7 +80,9 @@ function HeroSection() {

    {t("hero_section.subtitle")}

    - + Loading...
    }> + +
    diff --git a/apps/website/src/index.tsx b/apps/website/src/index.tsx index 1aafe83678..92455792a2 100644 --- a/apps/website/src/index.tsx +++ b/apps/website/src/index.tsx @@ -13,33 +13,30 @@ 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 { Suspense } from 'preact/compat'; export const LocaleContext = createContext('en'); export function App(props: {repoStargazersCount: number}) { return ( - - - Loading...}> -
    -
    - - - - + + +
    +
    + + + + - - - + + + - - -
    -
    - - - + + +
    +