Merge branch 'main' into fix/fix-equals-operator-in-search

This commit is contained in:
Jon Fuller
2025-10-28 08:41:47 -07:00
committed by GitHub
192 changed files with 4859 additions and 2448 deletions

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-bullseye-slim AS builder
FROM node:24.10.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.20.0-bullseye-slim
FROM node:24.10.0-bullseye-slim
# Install only runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-alpine AS builder
FROM node:24.10.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.20.0-alpine
FROM node:24.10.0-alpine
# Install runtime dependencies
RUN apk add --no-cache su-exec shadow

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-alpine AS builder
FROM node:24.10.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.20.0-alpine
FROM node:24.10.0-alpine
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-bullseye-slim AS builder
FROM node:24.10.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.20.0-bullseye-slim
FROM node:24.10.0-bullseye-slim
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/server",
"version": "0.99.2",
"version": "0.99.3",
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
"private": true,
"main": "./src/main.ts",
@@ -36,11 +36,12 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/express-partial-content": "workspace:*",
"@triliumnext/turndown-plugin-gfm": "workspace:*",
"@types/archiver": "6.0.3",
"@triliumnext/highlightjs": "workspace:*",
"@types/archiver": "7.0.0",
"@types/better-sqlite3": "7.6.13",
"@types/cls-hooked": "4.3.9",
"@types/compression": "1.8.1",
"@types/cookie-parser": "1.4.9",
"@types/cookie-parser": "1.4.10",
"@types/debounce": "1.2.4",
"@types/ejs": "3.1.5",
"@types/escape-html": "1.0.4",
@@ -56,18 +57,17 @@
"@types/sanitize-html": "2.16.0",
"@types/sax": "1.2.7",
"@types/serve-favicon": "2.5.7",
"@types/serve-static": "1.15.9",
"@types/session-file-store": "1.2.5",
"@types/serve-static": "2.2.0",
"@types/stream-throttle": "0.1.4",
"@types/supertest": "6.0.3",
"@types/swagger-ui-express": "4.1.8",
"@types/tmp": "0.2.6",
"@types/turndown": "5.0.5",
"@types/turndown": "5.0.6",
"@types/ws": "8.18.1",
"@types/xml2js": "0.4.14",
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"axios": "1.12.2",
"axios": "1.13.0",
"bindings": "1.5.0",
"bootstrap": "5.3.8",
"chardet": "2.1.0",
@@ -81,7 +81,7 @@
"debounce": "2.2.0",
"debug": "4.4.3",
"ejs": "3.1.10",
"electron": "38.3.0",
"electron": "38.4.0",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
@@ -100,7 +100,7 @@
"i18next": "25.6.0",
"i18next-fs-backend": "2.6.0",
"image-type": "6.0.0",
"ini": "5.0.0",
"ini": "6.0.0",
"is-animated": "2.0.2",
"is-svg": "6.1.0",
"jimp": "1.6.0",
@@ -110,7 +110,7 @@
"multer": "2.0.2",
"normalize-strings": "1.1.1",
"ollama": "0.6.0",
"openai": "6.6.0",
"openai": "6.7.0",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.3",
@@ -125,9 +125,9 @@
"swagger-ui-express": "5.0.1",
"time2fa": "1.4.2",
"tmp": "0.2.5",
"turndown": "7.2.1",
"turndown": "7.2.2",
"unescape": "1.0.1",
"vite": "7.1.11",
"vite": "7.1.12",
"ws": "8.18.3",
"xml2js": "0.6.2",
"yauzl": "3.2.0"

View File

@@ -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

View File

@@ -84,7 +84,9 @@
"show-backend-log": "فتح صفحة \"سجل الخلفية\"",
"edit-readonly-note": "تعديل ملاحظة القراءة فقط",
"attributes-labels-and-relations": "سمات ( تسميات و علاقات)",
"render-active-note": "عرض ( اعادة عرض) الملاحظة المؤرشفة"
"render-active-note": "عرض ( اعادة عرض) الملاحظة المؤرشفة",
"show-help": "فتح دليل التعليمات",
"copy-without-formatting": "نسخ النص المحدد بدون تنسيق"
},
"setup_sync-from-server": {
"note": "ملاحظة:",
@@ -196,7 +198,8 @@
"expand": "توسيع",
"site-theme": "المظهر العام للموقع",
"image_alt": "صورة المقال",
"on-this-page": "في هذه السفحة"
"on-this-page": "في هذه السفحة",
"last-updated": "اخر تحديث {{- date}}"
},
"hidden_subtree_templates": {
"description": "الوصف",
@@ -258,7 +261,8 @@
},
"share_page": {
"parent": "الأصل:",
"child-notes": "الملاحظات الفرعية:"
"child-notes": "الملاحظات الفرعية:",
"no-content": "لاتحتوي هذة الملاحظة على محتوى."
},
"notes": {
"duplicate-note-suffix": "(مكرر)",
@@ -339,7 +343,24 @@
"toggle-system-tray-icon": "تبديل ايقونة علبة النظام",
"switch-to-first-tab": "التبديل الى التبويب الاول",
"follow-link-under-cursor": "اتبع الرابط اسفل المؤشر",
"paste-markdown-into-text": "لصق نص بتنسبق Markdown"
"paste-markdown-into-text": "لصق نص بتنسبق Markdown",
"move-note-up-in-hierarchy": "نقل الملاحظة للاعلى في الهيكل",
"move-note-down-in-hierarchy": "نقل الملاحظة للاسفل في الهيكل",
"select-all-notes-in-parent": "تحديد جميع الملاحظات التابعة للملاحظة الاصل",
"add-note-above-to-selection": "اضافة ملاحظة فوق الملاحظة المحددة",
"add-note-below-to-selection": "اصافة ملاحظة اسفل الملاحظة المحددة",
"add-include-note-to-text": "اضافة الملاحظة الى النص",
"toggle-ribbon-tab-image-properties": "اظهار/ اخفاء صورة علامة التبويب في الشريط.",
"toggle-ribbon-tab-classic-editor": "عرض/اخفاء تبويب المحور الكلاسيكي",
"toggle-ribbon-tab-basic-properties": "عرض/اخفاء تبويب الخصائص الاساسية",
"toggle-ribbon-tab-book-properties": "عرض/اخفاء تبويب خصائص الدفتر",
"toggle-ribbon-tab-file-properties": "عرض/ادخفاء تبويب خصائص الملف",
"toggle-ribbon-tab-owned-attributes": "عرض/اخفاء تبويب المميزات المملوكة",
"toggle-ribbon-tab-inherited-attributes": "عرض/اخفاء تبويب السمات الموروثة",
"toggle-ribbon-tab-promoted-attributes": "عرض/ اخفاء تبويب السمات المعززة",
"toggle-ribbon-tab-note-map": "عرض/اخفاء تبويب خريطة الملاحظات",
"toggle-ribbon-tab-similar-notes": "عرض/اخفاء شريط الملاحظات المشابهة",
"export-active-note-as-pdf": "تصدير الملاحظة النشطة كملفPDF"
},
"share_404": {
"title": "غير موجود",
@@ -348,6 +369,7 @@
"weekdayNumber": "الاسبوع{رقم الاسيوع}",
"quarterNumber": "الربع {رقم الربع}",
"pdf": {
"export_filter": "مستند PDF (.pdf)"
"export_filter": "مستند PDF (.pdf)",
"unable-to-export-title": "تعذر التصدير كملف PDF"
}
}

View File

@@ -274,7 +274,8 @@
"export_filter": "PDF Dokument (*.pdf)",
"unable-to-export-message": "Die aktuelle Notiz konnte nicht als PDF exportiert werden.",
"unable-to-export-title": "Export als PDF fehlgeschlagen",
"unable-to-save-message": "Die ausgewählte Datei konnte nicht beschrieben werden. Erneut versuchen oder ein anderes Ziel auswählen."
"unable-to-save-message": "Die ausgewählte Datei konnte nicht beschrieben werden. Erneut versuchen oder ein anderes Ziel auswählen.",
"unable-to-print": "Notiz kann nicht gedruckt werden"
},
"tray": {
"tooltip": "Trilium Notes",

View File

@@ -250,7 +250,13 @@
"other": "Autre",
"advanced-title": "Avancé",
"visible-launchers-title": "Raccourcis visibles",
"user-guide": "Guide de l'utilisateur"
"user-guide": "Guide de l'utilisateur",
"jump-to-note-title": "Aller à...",
"llm-chat-title": "Discuter avec Notes",
"multi-factor-authentication-title": "MFA",
"ai-llm-title": "AI/LLM",
"localization": "Langue et région",
"inbox-title": "Boîte de réception"
},
"notes": {
"new-note": "Nouvelle note",
@@ -268,7 +274,8 @@
"export_filter": "Document PDF (*.pdf)",
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
"unable-to-export-title": "Impossible d'exporter au format PDF",
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination."
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination.",
"unable-to-print": "Impossible d'imprimer la note"
},
"tray": {
"tooltip": "Trilium Notes",
@@ -277,7 +284,8 @@
"bookmarks": "Signets",
"today": "Ouvrir la note du journal du jour",
"new-note": "Nouvelle note",
"show-windows": "Afficher les fenêtres"
"show-windows": "Afficher les fenêtres",
"open_new_window": "Ouvrir une nouvelle fenêtre"
},
"migration": {
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
@@ -375,7 +383,14 @@
"zoom-in": "Zoomer",
"reset-zoom-level": "Réinitilaliser le zoom",
"copy-without-formatting": "Copier sans mise en forme",
"force-save-revision": "Forcer la sauvegarde de la révision"
"force-save-revision": "Forcer la sauvegarde de la révision",
"toggle-ribbon-tab-promoted-attributes": "Basculer les attributs promus de l'onglet du ruban",
"toggle-ribbon-tab-note-map": "Basculer l'onglet du ruban Note Map",
"toggle-ribbon-tab-note-info": "Basculer l'onglet du ruban Note Info",
"toggle-ribbon-tab-note-paths": "Basculer les chemins de notes de l'onglet du ruban",
"toggle-ribbon-tab-similar-notes": "Basculer l'onglet du ruban Notes similaires",
"toggle-note-hoisting": "Activer la focalisation sur la note",
"unhoist-note": "Désactiver la focalisation sur la note"
},
"sql_init": {
"db_not_initialized_desktop": "Base de données non initialisée, merci de suivre les instructions à l'écran.",
@@ -383,5 +398,44 @@
},
"desktop": {
"instance_already_running": "Une instance est déjà en cours d'execution, ouverture de cette instance à la place."
},
"weekdayNumber": "Semaine {weekNumber}",
"quarterNumber": "Trimestre {quarterNumber}",
"share_theme": {
"site-theme": "Thème du site",
"search_placeholder": "Recherche...",
"image_alt": "Image de l'article",
"last-updated": "Dernière mise à jour le {{- date}}",
"subpages": "Sous-pages:",
"on-this-page": "Sur cette page",
"expand": "Développer"
},
"hidden_subtree_templates": {
"text-snippet": "Extrait de texte",
"description": "Description",
"list-view": "Vue en liste",
"grid-view": "Vue en grille",
"calendar": "Calendrier",
"table": "Tableau",
"geo-map": "Carte géographique",
"start-date": "Date de début",
"end-date": "Date de fin",
"start-time": "Heure de début",
"end-time": "Heure de fin",
"geolocation": "Géolocalisation",
"built-in-templates": "Modèles intégrés",
"board": "Tableau de bord",
"status": "État",
"board_note_first": "Première note",
"board_note_second": "Deuxième note",
"board_note_third": "Troisième note",
"board_status_todo": "A faire",
"board_status_progress": "En cours",
"board_status_done": "Terminé",
"presentation": "Présentation",
"presentation_slide": "Diapositive de présentation",
"presentation_slide_first": "Première diapositive",
"presentation_slide_second": "Deuxième diapositive",
"background": "Arrière-plan"
}
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -23,6 +23,14 @@
"edit-note-title": "Ugrás fáról a jegyzet részleteihez és a cím szerkesztése",
"edit-branch-prefix": "\"Ág címjelzésének szerkesztése\" ablak mutatása",
"clone-notes-to": "Kijelölt jegyzetek másolása",
"move-notes-to": "Kijelölt jegyzetek elhelyzése"
"move-notes-to": "Kijelölt jegyzetek elhelyzése",
"note-clipboard": "Megjegyzés vágólap",
"copy-notes-to-clipboard": "Másolja a kiválasztott jegyzeteket a vágólapra",
"paste-notes-from-clipboard": "A vágólapról szóló jegyzetek beillesztése aktív jegyzetbe",
"cut-notes-to-clipboard": "A kiválasztott jegyzetek kivágása a vágólapra",
"select-all-notes-in-parent": "Válassza ki az összes jegyzetet az aktuális jegyzetszintről",
"activate-next-tab": "Aktiválja a jobb oldali fület",
"activate-previous-tab": "Aktiválja a lapot a bal oldalon",
"open-new-window": "Nyiss új üres ablakot"
}
}

View File

@@ -165,7 +165,8 @@
"export_filter": "Documento PDF (*.pdf)",
"unable-to-export-message": "La nota corrente non può essere esportata come PDF.",
"unable-to-export-title": "Impossibile esportare come PDF",
"unable-to-save-message": "Il file selezionato non può essere salvato. Prova di nuovo o seleziona un'altra destinazione."
"unable-to-save-message": "Il file selezionato non può essere salvato. Prova di nuovo o seleziona un'altra destinazione.",
"unable-to-print": "Impossibile stampare la nota"
},
"tray": {
"tooltip": "Trilium Notes",
@@ -430,7 +431,8 @@
"presentation": "Presentazione",
"presentation_slide": "Diapositiva di presentazione",
"presentation_slide_first": "Prima diapositiva",
"presentation_slide_second": "Seconda diapositiva"
"presentation_slide_second": "Seconda diapositiva",
"background": "Contesto"
},
"sql_init": {
"db_not_initialized_desktop": "Database non inizializzato, seguire le istruzioni a schermo.",

View File

@@ -0,0 +1 @@
{}

View File

@@ -278,6 +278,11 @@ class BBranch extends AbstractBeccaEntity<BBranch> {
});
}
}
getParentNote() {
return this.parentNote;
}
}
export default BBranch;

View File

@@ -1758,6 +1758,26 @@ class BNote extends AbstractBeccaEntity<BNote> {
return childBranches;
}
get encodedTitle() {
return encodeURIComponent(this.title);
}
getVisibleChildBranches() {
return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
}
getVisibleChildNotes() {
return this.getVisibleChildBranches().map((branch) => branch.getNote());
}
hasVisibleChildren() {
return this.getVisibleChildNotes().length > 0;
}
get shareId() {
return this.noteId;
}
/**
* Return an attribute by it's attributeId. Requires the attribute cache to be available.
* @param attributeId - the id of the attribute owned by this note

View File

@@ -14,6 +14,7 @@ import type { ParsedQs } from "qs";
import type { NoteParams } from "../services/note-interface.js";
import type { SearchParams } from "../services/search/services/types.js";
import type { ValidatorMap } from "./etapi-interface.js";
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
function register(router: Router) {
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
@@ -149,8 +150,8 @@ function register(router: Router) {
const note = eu.getAndCheckNote(req.params.noteId);
const format = req.query.format || "html";
if (typeof format !== "string" || !["html", "markdown"].includes(format)) {
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`);
if (typeof format !== "string" || !["html", "markdown", "share"].includes(format)) {
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default), 'markdown' or 'share'.`);
}
const taskContext = new TaskContext("no-progress-reporting", "export", null);
@@ -159,7 +160,7 @@ function register(router: Router) {
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
const branch = note.getParentBranches()[0];
zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res);
zipExportService.exportToZip(taskContext, branch, format as ExportFormat, res);
});
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {

View File

@@ -26,7 +26,7 @@ function exportBranch(req: Request, res: Response) {
const taskContext = new TaskContext(taskId, "export", null);
try {
if (type === "subtree" && (format === "html" || format === "markdown")) {
if (type === "subtree" && (format === "html" || format === "markdown" || format === "share")) {
zipExportService.exportToZip(taskContext, branch, format, res);
} else if (type === "single") {
if (format !== "html" && format !== "markdown") {

View File

@@ -152,14 +152,14 @@ function restoreRevision(req: Request) {
}
function getEditedNotesOnDate(req: Request) {
const noteIds = sql.getColumn<string>(
`
const noteIds = sql.getColumn<string>(/*sql*/`\
SELECT notes.*
FROM notes
WHERE noteId IN (
SELECT noteId FROM notes
WHERE notes.dateCreated LIKE :date
OR notes.dateModified LIKE :date
WHERE
(notes.dateCreated LIKE :date OR notes.dateModified LIKE :date)
AND (noteId NOT LIKE '_%')
UNION ALL
SELECT noteId FROM revisions
WHERE revisions.dateLastEdited LIKE :date

View File

@@ -44,6 +44,7 @@ async function register(app: express.Application) {
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
}
app.use(`/share/assets/`, express.static(getShareThemeAssetDir()));
app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images")));
app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes")));
app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts")));
@@ -51,6 +52,16 @@ async function register(app: express.Application) {
app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets")));
}
export function getShareThemeAssetDir() {
if (process.env.NODE_ENV === "development") {
const srcRoot = path.join(__dirname, "..", "..");
return path.join(srcRoot, "../../packages/share-theme/dist");
} else {
const resourceDir = getResourceDir();
return path.join(resourceDir, "share-theme/assets");
}
}
export default {
register
};

View File

@@ -9,8 +9,9 @@ import type TaskContext from "../task_context.js";
import type BBranch from "../../becca/entities/bbranch.js";
import type { Response } from "express";
import type BNote from "../../becca/entities/bnote.js";
import type { ExportFormat } from "./zip/abstract_provider.js";
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) {
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) {
const note = branch.getNote();
if (note.type === "image" || note.type === "file") {
@@ -33,7 +34,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
taskContext.taskSucceeded(null);
}
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: "html" | "markdown") {
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) {
let payload, extension, mime;
if (typeof content !== "string") {

View File

@@ -1,12 +1,9 @@
"use strict";
import html from "html";
import dateUtils from "../date_utils.js";
import path from "path";
import mimeTypes from "mime-types";
import mdService from "./markdown.js";
import packageInfo from "../../../package.json" with { type: "json" };
import { getContentDisposition, escapeHtml, getResourceDir, isDev } from "../utils.js";
import { getContentDisposition } from "../utils.js";
import protectedSessionService from "../protected_session.js";
import sanitize from "sanitize-filename";
import fs from "fs";
@@ -18,39 +15,48 @@ import ValidationError from "../../errors/validation_error.js";
import type NoteMeta from "../meta/note_meta.js";
import type AttachmentMeta from "../meta/attachment_meta.js";
import type AttributeMeta from "../meta/attribute_meta.js";
import type BBranch from "../../becca/entities/bbranch.js";
import BBranch from "../../becca/entities/bbranch.js";
import type { Response } from "express";
import type { NoteMetaFile } from "../meta/note_meta.js";
import HtmlExportProvider from "./zip/html.js";
import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js";
import MarkdownExportProvider from "./zip/markdown.js";
import ShareThemeExportProvider from "./zip/share_theme.js";
import type BNote from "../../becca/entities/bnote.js";
import { NoteType } from "@triliumnext/commons";
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
export interface AdvancedExportOptions {
/**
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
*/
skipHtmlTemplate?: boolean;
/**
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
*
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
* @returns a function to rewrite the links in HTML or Markdown notes.
*/
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
}
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
if (!["html", "markdown"].includes(format)) {
throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`);
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
if (!["html", "markdown", "share"].includes(format)) {
throw new ValidationError(`Only 'html', 'markdown' and 'share' allowed as export format, '${format}' given`);
}
const archive = archiver("zip", {
zlib: { level: 9 } // Sets the compression level.
});
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
const provider = buildProvider();
const noteIdToMeta: Record<string, NoteMeta> = {};
function buildProvider() {
const providerData: ZipExportProviderData = {
getNoteTargetUrl,
archive,
branch,
rewriteFn
};
switch (format) {
case "html":
return new HtmlExportProvider(providerData);
case "markdown":
return new MarkdownExportProvider(providerData);
case "share":
return new ShareThemeExportProvider(providerData);
default:
throw new Error();
}
}
function getUniqueFilename(existingFileNames: Record<string, number>, fileName: string) {
const lcFileName = fileName.toLowerCase();
@@ -72,7 +78,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
}
}
function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
let fileName = baseFileName.trim();
if (!fileName) {
fileName = "note";
@@ -90,36 +96,14 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
}
let existingExtension = path.extname(fileName).toLowerCase();
let newExtension;
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
// and/or existing detected extensions in the note name
if (type === "text" && format === "markdown") {
newExtension = "md";
} else if (type === "text" && format === "html") {
newExtension = "html";
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
newExtension = "js";
} else if (type === "canvas" || mime === "application/json") {
newExtension = "json";
} else if (existingExtension.length > 0) {
// if the page already has an extension, then we'll just keep it
newExtension = null;
} else {
if (mime?.toLowerCase()?.trim() === "image/jpg") {
newExtension = "jpg";
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
newExtension = "txt";
} else {
newExtension = mimeTypes.extension(mime) || "dat";
}
}
const newExtension = provider.mapExtension(type, mime, existingExtension, format);
// if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again
if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) {
fileName += `.${newExtension}`;
}
return getUniqueFilename(existingFileNames, fileName);
}
@@ -145,7 +129,8 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
const notePath = parentMeta.notePath.concat([note.noteId]);
if (note.noteId in noteIdToMeta) {
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === "html" ? "html" : "md"}`);
const extension = provider.mapExtension("text", "text/html", "", format);
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${extension}`);
const meta: NoteMeta = {
isClone: true,
@@ -155,7 +140,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
prefix: branch.prefix,
dataFileName: fileName,
type: "text", // export will have text description
format: format
format: (format === "markdown" ? "markdown" : "html")
};
return meta;
}
@@ -185,7 +170,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
taskContext.increaseProgressCount();
if (note.type === "text") {
meta.format = format;
meta.format = (format === "markdown" ? "markdown" : "html");
}
noteIdToMeta[note.noteId] = meta as NoteMeta;
@@ -194,10 +179,13 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
note.sortChildren();
const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden");
const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable();
let shouldIncludeFile = (!note.isProtected || protectedSessionService.isProtectedSessionAvailable());
if (format !== "share") {
shouldIncludeFile = shouldIncludeFile && (note.getContent().length > 0 || childBranches.length === 0);
}
// if it's a leaf, then we'll export it even if it's empty
if (available && (note.getContent().length > 0 || childBranches.length === 0)) {
if (shouldIncludeFile) {
meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames);
}
@@ -273,8 +261,6 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
return url;
}
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
function rewriteLinks(content: string, noteMeta: NoteMeta): string {
content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => {
const url = getNoteTargetUrl(targetNoteId, noteMeta);
@@ -316,53 +302,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
}
}
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
if (["html", "markdown"].includes(noteMeta?.format || "")) {
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer {
const isText = ["html", "markdown"].includes(noteMeta?.format || "");
if (isText) {
content = content.toString();
content = rewriteFn(content, noteMeta);
}
if (noteMeta.format === "html" && typeof content === "string") {
if (!content.substr(0, 100).toLowerCase().includes("<html") && !zipExportOptions?.skipHtmlTemplate) {
if (!noteMeta?.notePath?.length) {
throw new Error("Missing note path.");
}
content = provider.prepareContent(title, content, noteMeta, note, branch);
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
const htmlTitle = escapeHtml(title);
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
content = `<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="${cssUrl}">
<base target="_parent">
<title data-trilium-title>${htmlTitle}</title>
</head>
<body>
<div class="content">
<h1 data-trilium-h1>${htmlTitle}</h1>
<div class="ck-content">${content}</div>
</div>
</body>
</html>`;
}
return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content;
} else if (noteMeta.format === "markdown" && typeof content === "string") {
let markdownContent = mdService.toMarkdown(content);
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
markdownContent = `# ${title}\r
${markdownContent}`;
}
return markdownContent;
} else {
return content;
}
return content;
}
function saveNote(noteMeta: NoteMeta, filePathPrefix: string) {
@@ -377,7 +325,7 @@ ${markdownContent}`;
let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`;
content = prepareContent(noteMeta.title, content, noteMeta);
content = prepareContent(noteMeta.title, content, noteMeta, undefined);
archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
@@ -393,7 +341,7 @@ ${markdownContent}`;
}
if (noteMeta.dataFileName) {
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta);
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note);
archive.append(content, {
name: filePathPrefix + noteMeta.dataFileName,
@@ -429,138 +377,21 @@ ${markdownContent}`;
}
}
function saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
if (!navigationMeta.dataFileName) {
return;
}
function saveNavigationInner(meta: NoteMeta) {
let html = "<li>";
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
if (meta.dataFileName && meta.noteId) {
const targetUrl = getNoteTargetUrl(meta.noteId, rootMeta);
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
} else {
html += escapedTitle;
}
if (meta.children && meta.children.length > 0) {
html += "<ul>";
for (const child of meta.children) {
html += saveNavigationInner(child);
}
html += "</ul>";
}
return `${html}</li>`;
}
const fullHtml = `<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css">
</head>
<body>
<ul>${saveNavigationInner(rootMeta)}</ul>
</body>
</html>`;
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
archive.append(prettyHtml, { name: navigationMeta.dataFileName });
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
if (!rootMeta) {
throw new Error("Unable to create root meta.");
}
function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
let firstNonEmptyNote;
let curMeta = rootMeta;
const metaFile: NoteMetaFile = {
formatVersion: 2,
appVersion: packageInfo.version,
files: [rootMeta]
};
if (!indexMeta.dataFileName) {
return;
}
while (!firstNonEmptyNote) {
if (curMeta.dataFileName && curMeta.noteId) {
firstNonEmptyNote = getNoteTargetUrl(curMeta.noteId, rootMeta);
}
if (curMeta.children && curMeta.children.length > 0) {
curMeta = curMeta.children[0];
} else {
break;
}
}
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<frameset cols="25%,75%">
<frame name="navigation" src="navigation.html">
<frame name="detail" src="${firstNonEmptyNote}">
</frameset>
</html>`;
archive.append(fullHtml, { name: indexMeta.dataFileName });
}
function saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
if (!cssMeta.dataFileName) {
return;
}
const cssFile = isDev
? path.join(__dirname, "../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
: path.join(getResourceDir(), "ckeditor5-content.css");
archive.append(fs.readFileSync(cssFile, "utf-8"), { name: cssMeta.dataFileName });
}
provider.prepareMeta(metaFile);
try {
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
if (!rootMeta) {
throw new Error("Unable to create root meta.");
}
const metaFile: NoteMetaFile = {
formatVersion: 2,
appVersion: packageInfo.version,
files: [rootMeta]
};
let navigationMeta: NoteMeta | null = null;
let indexMeta: NoteMeta | null = null;
let cssMeta: NoteMeta | null = null;
if (format === "html") {
navigationMeta = {
noImport: true,
dataFileName: "navigation.html"
};
metaFile.files.push(navigationMeta);
indexMeta = {
noImport: true,
dataFileName: "index.html"
};
metaFile.files.push(indexMeta);
cssMeta = {
noImport: true,
dataFileName: "style.css"
};
metaFile.files.push(cssMeta);
}
for (const noteMeta of Object.values(noteIdToMeta)) {
// filter out relations which are not inside this export
noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => {
@@ -584,34 +415,6 @@ ${markdownContent}`;
}
return;
}
const metaFileJson = JSON.stringify(metaFile, null, "\t");
archive.append(metaFileJson, { name: "!!!meta.json" });
saveNote(rootMeta, "");
if (format === "html") {
if (!navigationMeta || !indexMeta || !cssMeta) {
throw new Error("Missing meta.");
}
saveNavigation(rootMeta, navigationMeta);
saveIndex(rootMeta, indexMeta);
saveCss(rootMeta, cssMeta);
}
const note = branch.getNote();
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected() || "note"}.zip`;
if (setHeaders && "setHeader" in res) {
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
res.setHeader("Content-Type", "application/zip");
}
archive.pipe(res);
await archive.finalize();
taskContext.taskSucceeded(null);
} catch (e: unknown) {
const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`;
log.error(message);
@@ -623,9 +426,30 @@ ${markdownContent}`;
res.status(500).send(message);
}
}
const metaFileJson = JSON.stringify(metaFile, null, "\t");
archive.append(metaFileJson, { name: "!!!meta.json" });
saveNote(rootMeta, "");
provider.afterDone(rootMeta);
const note = branch.getNote();
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`;
if (setHeaders && "setHeader" in res) {
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
res.setHeader("Content-Type", "application/zip");
}
archive.pipe(res);
await archive.finalize();
taskContext.taskSucceeded(null);
}
async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
const fileOutputStream = fs.createWriteStream(zipFilePath);
const taskContext = new TaskContext("no-progress-reporting", "export", null);

View File

@@ -0,0 +1,89 @@
import { Archiver } from "archiver";
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
import type BNote from "../../../becca/entities/bnote.js";
import type BBranch from "../../../becca/entities/bbranch.js";
import mimeTypes from "mime-types";
import { NoteType } from "@triliumnext/commons";
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
export type ExportFormat = "html" | "markdown" | "share";
export interface AdvancedExportOptions {
/**
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
*/
skipHtmlTemplate?: boolean;
/**
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
*
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
* @returns a function to rewrite the links in HTML or Markdown notes.
*/
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
}
export interface ZipExportProviderData {
branch: BBranch;
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
archive: Archiver;
zipExportOptions?: AdvancedExportOptions;
rewriteFn: RewriteLinksFn;
}
export abstract class ZipExportProvider {
branch: BBranch;
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
archive: Archiver;
zipExportOptions?: AdvancedExportOptions;
rewriteFn: RewriteLinksFn;
constructor(data: ZipExportProviderData) {
this.branch = data.branch;
this.getNoteTargetUrl = data.getNoteTargetUrl;
this.archive = data.archive;
this.zipExportOptions = data.zipExportOptions;
this.rewriteFn = data.rewriteFn;
}
abstract prepareMeta(metaFile: NoteMetaFile): void;
abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer;
abstract afterDone(rootMeta: NoteMeta): void;
/**
* Determines the extension of the resulting file for a specific note type.
*
* @param type the type of the note.
* @param mime the mime type of the note.
* @param existingExtension the existing extension, including the leading period character.
* @param format the format requested for export (e.g. HTML, Markdown).
* @returns an extension *without* the leading period character, or `null` to preserve the existing extension instead.
*/
mapExtension(type: NoteType | null, mime: string, existingExtension: string, format: ExportFormat) {
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
// and/or existing detected extensions in the note name
if (type === "text" && format === "markdown") {
return "md";
} else if (type === "text" && format === "html") {
return "html";
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
return "js";
} else if (type === "canvas" || mime === "application/json") {
return "json";
} else if (existingExtension.length > 0) {
// if the page already has an extension, then we'll just keep it
return null;
} else {
if (mime?.toLowerCase()?.trim() === "image/jpg") {
return "jpg";
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
return "txt";
} else {
return mimeTypes.extension(mime) || "dat";
}
}
}
}

View File

@@ -0,0 +1,176 @@
import type NoteMeta from "../../meta/note_meta.js";
import { escapeHtml, getResourceDir, isDev } from "../../utils";
import html from "html";
import { ZipExportProvider } from "./abstract_provider.js";
import path from "path";
import fs from "fs";
export default class HtmlExportProvider extends ZipExportProvider {
private navigationMeta: NoteMeta | null = null;
private indexMeta: NoteMeta | null = null;
private cssMeta: NoteMeta | null = null;
prepareMeta(metaFile) {
this.navigationMeta = {
noImport: true,
dataFileName: "navigation.html"
};
metaFile.files.push(this.navigationMeta);
this.indexMeta = {
noImport: true,
dataFileName: "index.html"
};
metaFile.files.push(this.indexMeta);
this.cssMeta = {
noImport: true,
dataFileName: "style.css"
};
metaFile.files.push(this.cssMeta);
}
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
if (noteMeta.format === "html" && typeof content === "string") {
if (!content.substr(0, 100).toLowerCase().includes("<html") && !this.zipExportOptions?.skipHtmlTemplate) {
if (!noteMeta?.notePath?.length) {
throw new Error("Missing note path.");
}
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
const htmlTitle = escapeHtml(title);
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
content = `<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="${cssUrl}">
<base target="_parent">
<title data-trilium-title>${htmlTitle}</title>
</head>
<body>
<div class="content">
<h1 data-trilium-h1>${htmlTitle}</h1>
<div class="ck-content">${content}</div>
</div>
</body>
</html>`;
}
if (content.length < 100_000) {
content = html.prettyPrint(content, { indent_size: 2 })
}
content = this.rewriteFn(content as string, noteMeta);
return content;
} else {
return content;
}
}
afterDone(rootMeta: NoteMeta) {
if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) {
throw new Error("Missing meta.");
}
this.#saveNavigation(rootMeta, this.navigationMeta);
this.#saveIndex(rootMeta, this.indexMeta);
this.#saveCss(rootMeta, this.cssMeta);
}
#saveNavigationInner(rootMeta: NoteMeta, meta: NoteMeta) {
let html = "<li>";
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
if (meta.dataFileName && meta.noteId) {
const targetUrl = this.getNoteTargetUrl(meta.noteId, rootMeta);
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
} else {
html += escapedTitle;
}
if (meta.children && meta.children.length > 0) {
html += "<ul>";
for (const child of meta.children) {
html += this.#saveNavigationInner(rootMeta, child);
}
html += "</ul>";
}
return `${html}</li>`;
}
#saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
if (!navigationMeta.dataFileName) {
return;
}
const fullHtml = `<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css">
</head>
<body>
<ul>${this.#saveNavigationInner(rootMeta, rootMeta)}</ul>
</body>
</html>`;
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
this.archive.append(prettyHtml, { name: navigationMeta.dataFileName });
}
#saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
let firstNonEmptyNote;
let curMeta = rootMeta;
if (!indexMeta.dataFileName) {
return;
}
while (!firstNonEmptyNote) {
if (curMeta.dataFileName && curMeta.noteId) {
firstNonEmptyNote = this.getNoteTargetUrl(curMeta.noteId, rootMeta);
}
if (curMeta.children && curMeta.children.length > 0) {
curMeta = curMeta.children[0];
} else {
break;
}
}
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<frameset cols="25%,75%">
<frame name="navigation" src="navigation.html">
<frame name="detail" src="${firstNonEmptyNote}">
</frameset>
</html>`;
this.archive.append(fullHtml, { name: indexMeta.dataFileName });
}
#saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
if (!cssMeta.dataFileName) {
return;
}
const cssFile = isDev
? path.join(__dirname, "../../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
: path.join(getResourceDir(), "ckeditor5-content.css");
const cssContent = fs.readFileSync(cssFile, "utf-8");
this.archive.append(cssContent, { name: cssMeta.dataFileName });
}
}

View File

@@ -0,0 +1,27 @@
import NoteMeta from "../../meta/note_meta"
import { ZipExportProvider } from "./abstract_provider.js"
import mdService from "../markdown.js";
export default class MarkdownExportProvider extends ZipExportProvider {
prepareMeta() { }
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
if (noteMeta.format === "markdown" && typeof content === "string") {
let markdownContent = mdService.toMarkdown(content);
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
markdownContent = `# ${title}\r
${markdownContent}`;
}
markdownContent = this.rewriteFn(markdownContent, noteMeta);
return markdownContent;
} else {
return content;
}
}
afterDone() { }
}

View File

@@ -0,0 +1,115 @@
import { join } from "path";
import NoteMeta, { NoteMetaFile } from "../../meta/note_meta";
import { ExportFormat, ZipExportProvider } from "./abstract_provider.js";
import { RESOURCE_DIR } from "../../resource_dir";
import { getResourceDir, isDev } from "../../utils";
import fs, { readdirSync } from "fs";
import { renderNoteForExport } from "../../../share/content_renderer";
import type BNote from "../../../becca/entities/bnote.js";
import type BBranch from "../../../becca/entities/bbranch.js";
import { getShareThemeAssetDir } from "../../../routes/assets";
const shareThemeAssetDir = getShareThemeAssetDir();
export default class ShareThemeExportProvider extends ZipExportProvider {
private assetsMeta: NoteMeta[] = [];
private indexMeta: NoteMeta | null = null;
prepareMeta(metaFile: NoteMetaFile): void {
const assets = [
"icon-color.svg"
];
for (const file of readdirSync(shareThemeAssetDir)) {
assets.push(`assets/${file}`);
}
for (const asset of assets) {
const assetMeta = {
noImport: true,
dataFileName: asset
};
this.assetsMeta.push(assetMeta);
metaFile.files.push(assetMeta);
}
this.indexMeta = {
noImport: true,
dataFileName: "index.html"
};
metaFile.files.push(this.indexMeta);
}
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer {
if (!noteMeta?.notePath?.length) {
throw new Error("Missing note path.");
}
const basePath = "../".repeat(noteMeta.notePath.length - 1);
if (note) {
content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1));
if (typeof content === "string") {
content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, (match, id) => {
if (match.includes("/assets/")) return match;
return `href="#root/${id}"`;
});
content = this.rewriteFn(content, noteMeta);
}
}
return content;
}
afterDone(rootMeta: NoteMeta): void {
this.#saveAssets(rootMeta, this.assetsMeta);
this.#saveIndex(rootMeta);
}
mapExtension(type: string | null, mime: string, existingExtension: string, format: ExportFormat): string | null {
if (mime.startsWith("image/")) {
return null;
}
return "html";
}
#saveIndex(rootMeta: NoteMeta) {
if (!this.indexMeta?.dataFileName) {
return;
}
const note = this.branch.getNote();
const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch);
this.archive.append(fullHtml, { name: this.indexMeta.dataFileName });
}
#saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) {
for (const assetMeta of assetsMeta) {
if (!assetMeta.dataFileName) {
continue;
}
let cssContent = getShareThemeAssets(assetMeta.dataFileName);
this.archive.append(cssContent, { name: assetMeta.dataFileName });
}
}
}
function getShareThemeAssets(nameWithExtension: string) {
let path: string | undefined;
if (nameWithExtension === "icon-color.svg") {
path = join(RESOURCE_DIR, "images", nameWithExtension);
} else if (nameWithExtension.startsWith("assets")) {
path = join(shareThemeAssetDir, nameWithExtension.replace(/^assets\//, ""));
} else if (isDev) {
path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension);
} else {
path = join(getResourceDir(), "public", "src", nameWithExtension);
}
return fs.readFileSync(path);
}

View File

@@ -16,6 +16,7 @@ export const DAYJS_LOADER: Record<LOCALE_IDS, () => Promise<typeof import("dayjs
"es": () => import("dayjs/locale/es.js"),
"fa": () => import("dayjs/locale/fa.js"),
"fr": () => import("dayjs/locale/fr.js"),
"it": () => import("dayjs/locale/it.js"),
"he": () => import("dayjs/locale/he.js"),
"ja": () => import("dayjs/locale/ja.js"),
"ku": () => import("dayjs/locale/ku.js"),

View File

@@ -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) */

View File

@@ -66,7 +66,7 @@ sqlInit.dbReady.then(() => {
);
}
setInterval(() => checkProtectedSessionExpiration(), 1);
setInterval(() => checkProtectedSessionExpiration(), 30000);
});
function checkProtectedSessionExpiration() {

View File

@@ -681,3 +681,34 @@ describe("#normalizeCustomHandlerPattern", () => {
});
});
});
describe("#slugify", () => {
it("should return a slugified string", () => {
const testString = "This is a Test String! With unicode & Special #Chars.";
const expectedSlug = "this-is-a-test-string-with-unicode-special-chars";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
it("supports CJK characters without alteration", () => {
const testString = "测试中文字符";
const expectedSlug = "测试中文字符";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
it("supports Cyrillic characters without alteration", () => {
const testString = "Тестирование кириллических символов";
const expectedSlug = "тестирование-кириллических-символов";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
// preserves diacritic marks
it("preserves diacritic marks", () => {
const testString = "Café naïve façade jalapeño";
const expectedSlug = "café-naïve-façade-jalapeño";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
});

View File

@@ -497,6 +497,14 @@ export function formatSize(size: number | null | undefined) {
}
}
function slugify(text: string) {
return text
.normalize("NFC") // keep composed form, preserves accents
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, "-") // replace non-letter/number with "-"
.replace(/(^-|-$)+/g, ""); // trim dashes
}
export default {
compareVersions,
crash,
@@ -532,6 +540,7 @@ export default {
safeExtractMessageAndStackFromError,
sanitizeSqlIdentifier,
stripTags,
slugify,
timeLimit,
toBase64,
toMap,

View File

@@ -1,10 +1,23 @@
import { parse, HTMLElement, TextNode } from "node-html-parser";
import { parse, HTMLElement, TextNode, Options } from "node-html-parser";
import shaca from "./shaca/shaca.js";
import assetPath from "../services/asset_path.js";
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
import shareRoot from "./share_root.js";
import escapeHtml from "escape-html";
import type SNote from "./shaca/entities/snote.js";
import BNote from "../becca/entities/bnote.js";
import type BBranch from "../becca/entities/bbranch.js";
import { t } from "i18next";
import SBranch from "./shaca/entities/sbranch.js";
import options from "../services/options.js";
import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
import ejs from "ejs";
import log from "../services/log.js";
import { join } from "path";
import { readFileSync } from "fs";
import { highlightAuto } from "@triliumnext/highlightjs";
const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`;
const templateCache: Map<string, string> = new Map();
/**
* Represents the output of the content renderer.
@@ -16,7 +29,192 @@ export interface Result {
isEmpty?: boolean;
}
export function getContent(note: SNote) {
interface Subroot {
note?: SNote | BNote;
branch?: SBranch | BBranch
}
function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
// share root itself is not shared
return {};
}
// every path leads to share root, but which one to choose?
// for the sake of simplicity, URLs are not note paths
const parentBranch = note.getParentBranches()[0];
if (note instanceof BNote) {
return {
note,
branch: parentBranch
}
}
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
return {
note,
branch: parentBranch
};
}
return getSharedSubTreeRoot(parentBranch.getParentNote());
}
export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[]) {
const subRoot: Subroot = {
branch: parentBranch,
note: parentBranch.getNote()
};
return renderNoteContentInternal(note, {
subRoot,
rootNoteId: parentBranch.noteId,
cssToLoad: [
`${basePath}assets/styles.css`,
`${basePath}assets/scripts.css`,
],
jsToLoad: [
`${basePath}assets/scripts.js`
],
logoUrl: `${basePath}icon-color.svg`,
ancestors
});
}
export function renderNoteContent(note: SNote) {
const subRoot = getSharedSubTreeRoot(note);
const ancestors: string[] = [];
let notePointer = note;
while (notePointer.parents[0]?.noteId !== subRoot.note?.noteId) {
const pointerParent = notePointer.parents[0];
if (!pointerParent) {
break;
}
ancestors.push(pointerParent.noteId);
notePointer = pointerParent;
}
// Determine CSS to load.
const cssToLoad: string[] = [];
if (!note.isLabelTruthy("shareOmitDefaultCss")) {
cssToLoad.push(`assets/styles.css`);
cssToLoad.push(`assets/scripts.css`);
}
for (const cssRelation of note.getRelations("shareCss")) {
cssToLoad.push(`api/notes/${cssRelation.value}/download`);
}
// Determine JS to load.
const jsToLoad: string[] = [
"assets/scripts.js"
];
for (const jsRelation of note.getRelations("shareJs")) {
jsToLoad.push(`api/notes/${jsRelation.value}/download`);
}
const customLogoId = note.getRelation("shareLogo")?.value;
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`;
return renderNoteContentInternal(note, {
subRoot,
rootNoteId: "_share",
cssToLoad,
jsToLoad,
logoUrl,
ancestors
});
}
interface RenderArgs {
subRoot: Subroot;
rootNoteId: string;
cssToLoad: string[];
jsToLoad: string[];
logoUrl: string;
ancestors: string[];
}
function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) {
const { header, content, isEmpty } = getContent(note);
const showLoginInShareTheme = options.getOption("showLoginInShareTheme");
const opts = {
note,
header,
content,
isEmpty,
assetPath: shareAdjustedAssetPath,
assetUrlFragment,
showLoginInShareTheme,
t,
isDev,
utils,
...renderArgs
};
// Check if the user has their own template.
if (note.hasRelation("shareTemplate")) {
// Get the template note and content
const templateId = note.getRelation("shareTemplate")?.value;
const templateNote = templateId && shaca.getNote(templateId);
// Make sure the note type is correct
if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") {
// EJS caches the result of this so we don't need to pre-cache
const includer = (path: string) => {
const childNote = templateNote.children.find((n) => path === n.title);
if (!childNote) throw new Error(`Unable to find child note: ${path}.`);
if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type.");
const template = childNote.getContent();
if (typeof template !== "string") throw new Error("Invalid template content type.");
return { template };
};
// Try to render user's template, w/ fallback to default view
try {
const content = templateNote.getContent();
if (typeof content === "string") {
return ejs.render(content, opts, { includer });
}
} catch (e: unknown) {
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`);
}
}
}
// Render with the default view otherwise.
const templatePath = getDefaultTemplatePath("page");
return ejs.render(readTemplate(templatePath), opts, {
includer: (path) => {
// Path is relative to apps/server/dist/assets/views
return { template: readTemplate(getDefaultTemplatePath(path)) };
}
});
}
function getDefaultTemplatePath(template: string) {
// Path is relative to apps/server/dist/assets/views
return process.env.NODE_ENV === "development"
? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`)
: join(getResourceDir(), `share-theme/templates/${template}.ejs`);
}
function readTemplate(path: string) {
const cachedTemplate = templateCache.get(path);
if (cachedTemplate) {
return cachedTemplate;
}
const templateString = readFileSync(path, "utf-8");
templateCache.set(path, templateString);
return templateString;
}
export function getContent(note: SNote | BNote) {
if (note.isProtected) {
return {
header: "",
@@ -65,9 +263,12 @@ function renderIndex(result: Result) {
result.content += "</ul>";
}
function renderText(result: Result, note: SNote) {
function renderText(result: Result, note: SNote | BNote) {
if (typeof result.content !== "string") return;
const document = parse(result.content || "");
const parseOpts: Partial<Options> = {
blockTextElements: {}
}
const document = parse(result.content || "", parseOpts);
// Process include notes.
for (const includeNoteEl of document.querySelectorAll("section.include-note")) {
@@ -80,7 +281,7 @@ function renderText(result: Result, note: SNote) {
const includedResult = getContent(note);
if (typeof includedResult.content !== "string") continue;
const includedDocument = parse(includedResult.content).childNodes;
const includedDocument = parse(includedResult.content, parseOpts).childNodes;
if (includedDocument) {
includeNoteEl.replaceWith(...includedDocument);
}
@@ -89,6 +290,7 @@ function renderText(result: Result, note: SNote) {
result.isEmpty = document.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0;
if (!result.isEmpty) {
// Process attachment links.
for (const linkEl of document.querySelectorAll("a")) {
const href = linkEl.getAttribute("href");
@@ -102,21 +304,15 @@ function renderText(result: Result, note: SNote) {
}
}
result.content = document.innerHTML ?? "";
if (result.content.includes(`<span class="math-tex">`)) {
result.header += `
<script src="../${assetPath}/node_modules/katex/dist/katex.min.js"></script>
<link rel="stylesheet" href="../${assetPath}/node_modules/katex/dist/katex.min.css">
<script src="../${assetPath}/node_modules/katex/dist/contrib/auto-render.min.js"></script>
<script src="../${assetPath}/node_modules/katex/dist/contrib/mhchem.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
renderMathInElement(document.getElementById('content'));
});
</script>`;
// Apply syntax highlight.
for (const codeEl of document.querySelectorAll("pre code")) {
const highlightResult = highlightAuto(codeEl.innerText);
codeEl.innerHTML = highlightResult.value;
codeEl.classList.add("hljs");
}
result.content = document.innerHTML ?? "";
if (note.hasLabel("shareIndex")) {
renderIndex(result);
}
@@ -174,7 +370,7 @@ export function renderCode(result: Result) {
}
}
function renderMermaid(result: Result, note: SNote) {
function renderMermaid(result: Result, note: SNote | BNote) {
if (typeof result.content !== "string") {
return;
}
@@ -188,11 +384,11 @@ function renderMermaid(result: Result, note: SNote) {
</details>`;
}
function renderImage(result: Result, note: SNote) {
function renderImage(result: Result, note: SNote | BNote) {
result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">`;
}
function renderFile(note: SNote, result: Result) {
function renderFile(note: SNote | BNote, result: Result) {
if (note.mime === "application/pdf") {
result.content = `<iframe class="pdf-view" src="api/notes/${note.noteId}/view"></iframe>`;
} else {

View File

@@ -4,41 +4,12 @@ import type { Request, Response, Router } from "express";
import shaca from "./shaca/shaca.js";
import shacaLoader from "./shaca/shaca_loader.js";
import shareRoot from "./share_root.js";
import contentRenderer from "./content_renderer.js";
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
import appPath from "../services/app_path.js";
import searchService from "../services/search/services/search.js";
import SearchContext from "../services/search/search_context.js";
import log from "../services/log.js";
import type SNote from "./shaca/entities/snote.js";
import type SBranch from "./shaca/entities/sbranch.js";
import type SAttachment from "./shaca/entities/sattachment.js";
import utils, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
import options from "../services/options.js";
import { t } from "i18next";
import ejs from "ejs";
import { join } from "path";
function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } {
if (note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
// share root itself is not shared
return {};
}
// every path leads to share root, but which one to choose?
// for the sake of simplicity, URLs are not note paths
const parentBranch = note.getParentBranches()[0];
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
return {
note,
branch: parentBranch
};
}
return getSharedSubTreeRoot(parentBranch.getParentNote());
}
import { renderNoteContent } from "./content_renderer.js";
import utils from "../services/utils.js";
function addNoIndexHeader(note: SNote, res: Response) {
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
@@ -109,8 +80,7 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
let svgString = "<svg/>";
const attachment = image.getAttachmentByTitle(attachmentName);
if (!attachment) {
res.status(404);
renderDefault(res, "404");
return;
}
const content = attachment.getContent();
@@ -138,12 +108,19 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
res.send(svg);
}
function render404(res: Response) {
res.status(404);
const shareThemePath = `../../share-theme/templates/404.ejs`;
res.render(shareThemePath);
}
function register(router: Router) {
function renderNote(note: SNote, req: Request, res: Response) {
if (!note) {
console.log("Unable to find note ", note);
res.status(404);
renderDefault(res, "404");
render404(res);
return;
}
@@ -161,62 +138,7 @@ function register(router: Router) {
return;
}
const { header, content, isEmpty } = contentRenderer.getContent(note);
const subRoot = getSharedSubTreeRoot(note);
const showLoginInShareTheme = options.getOption("showLoginInShareTheme");
const opts = {
note,
header,
content,
isEmpty,
subRoot,
assetPath: isDev ? assetPath : `../${assetPath}`,
assetUrlFragment,
appPath: isDev ? appPath : `../${appPath}`,
showLoginInShareTheme,
t,
isDev
};
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) => {
@@ -400,14 +322,6 @@ function register(router: Router) {
});
}
function renderDefault(res: Response<any, Record<string, any>>, template: "page" | "404", opts: any = {}) {
// Path is relative to apps/server/dist/assets/views
const shareThemePath = process.env.NODE_ENV === "development"
? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`)
: `../../share-theme/templates/${template}.ejs`;
res.render(shareThemePath, opts);
}
export default {
register
};

View File

@@ -38,3 +38,9 @@ declare module "@triliumnext/share-theme/styles.css" {
const content: string;
export default content;
}
declare module '*.css' {}
declare module '*?raw' {
const src: string
export default src
}