mirror of
https://github.com/zadam/trilium.git
synced 2025-11-14 09:15:50 +01:00
Merge branch 'main' into fix/fix-equals-operator-in-search
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/server/src/assets/translations/hi/server.json
Normal file
1
apps/server/src/assets/translations/hi/server.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
1
apps/server/src/assets/translations/mr/server.json
Normal file
1
apps/server/src/assets/translations/mr/server.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -278,6 +278,11 @@ class BBranch extends AbstractBeccaEntity<BBranch> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getParentNote() {
|
||||
return this.parentNote;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default BBranch;
|
||||
|
||||
@@ -1758,6 +1758,26 @@ class BNote extends AbstractBeccaEntity<BNote> {
|
||||
return childBranches;
|
||||
}
|
||||
|
||||
get encodedTitle() {
|
||||
return encodeURIComponent(this.title);
|
||||
}
|
||||
|
||||
getVisibleChildBranches() {
|
||||
return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
|
||||
}
|
||||
|
||||
getVisibleChildNotes() {
|
||||
return this.getVisibleChildBranches().map((branch) => branch.getNote());
|
||||
}
|
||||
|
||||
hasVisibleChildren() {
|
||||
return this.getVisibleChildNotes().length > 0;
|
||||
}
|
||||
|
||||
get shareId() {
|
||||
return this.noteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an attribute by it's attributeId. Requires the attribute cache to be available.
|
||||
* @param attributeId - the id of the attribute owned by this note
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ParsedQs } from "qs";
|
||||
import type { NoteParams } from "../services/note-interface.js";
|
||||
import type { SearchParams } from "../services/search/services/types.js";
|
||||
import type { ValidatorMap } from "./etapi-interface.js";
|
||||
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
|
||||
|
||||
function register(router: Router) {
|
||||
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
|
||||
@@ -149,8 +150,8 @@ function register(router: Router) {
|
||||
const note = eu.getAndCheckNote(req.params.noteId);
|
||||
const format = req.query.format || "html";
|
||||
|
||||
if (typeof format !== "string" || !["html", "markdown"].includes(format)) {
|
||||
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`);
|
||||
if (typeof format !== "string" || !["html", "markdown", "share"].includes(format)) {
|
||||
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default), 'markdown' or 'share'.`);
|
||||
}
|
||||
|
||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||
@@ -159,7 +160,7 @@ function register(router: Router) {
|
||||
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
|
||||
const branch = note.getParentBranches()[0];
|
||||
|
||||
zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res);
|
||||
zipExportService.exportToZip(taskContext, branch, format as ExportFormat, res);
|
||||
});
|
||||
|
||||
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ function exportBranch(req: Request, res: Response) {
|
||||
const taskContext = new TaskContext(taskId, "export", null);
|
||||
|
||||
try {
|
||||
if (type === "subtree" && (format === "html" || format === "markdown")) {
|
||||
if (type === "subtree" && (format === "html" || format === "markdown" || format === "share")) {
|
||||
zipExportService.exportToZip(taskContext, branch, format, res);
|
||||
} else if (type === "single") {
|
||||
if (format !== "html" && format !== "markdown") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,7 @@ async function register(app: express.Application) {
|
||||
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
|
||||
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
|
||||
}
|
||||
app.use(`/share/assets/`, express.static(getShareThemeAssetDir()));
|
||||
app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images")));
|
||||
app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes")));
|
||||
app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts")));
|
||||
@@ -51,6 +52,16 @@ async function register(app: express.Application) {
|
||||
app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets")));
|
||||
}
|
||||
|
||||
export function getShareThemeAssetDir() {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const srcRoot = path.join(__dirname, "..", "..");
|
||||
return path.join(srcRoot, "../../packages/share-theme/dist");
|
||||
} else {
|
||||
const resourceDir = getResourceDir();
|
||||
return path.join(resourceDir, "share-theme/assets");
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
register
|
||||
};
|
||||
|
||||
@@ -9,8 +9,9 @@ import type TaskContext from "../task_context.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type { ExportFormat } from "./zip/abstract_provider.js";
|
||||
|
||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) {
|
||||
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) {
|
||||
const note = branch.getNote();
|
||||
|
||||
if (note.type === "image" || note.type === "file") {
|
||||
@@ -33,7 +34,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: "html" | "markdown") {
|
||||
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: ExportFormat) {
|
||||
let payload, extension, mime;
|
||||
|
||||
if (typeof content !== "string") {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use strict";
|
||||
|
||||
import html from "html";
|
||||
import dateUtils from "../date_utils.js";
|
||||
import path from "path";
|
||||
import mimeTypes from "mime-types";
|
||||
import mdService from "./markdown.js";
|
||||
import packageInfo from "../../../package.json" with { type: "json" };
|
||||
import { getContentDisposition, escapeHtml, getResourceDir, isDev } from "../utils.js";
|
||||
import { getContentDisposition } from "../utils.js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import sanitize from "sanitize-filename";
|
||||
import fs from "fs";
|
||||
@@ -18,39 +15,48 @@ import ValidationError from "../../errors/validation_error.js";
|
||||
import type NoteMeta from "../meta/note_meta.js";
|
||||
import type AttachmentMeta from "../meta/attachment_meta.js";
|
||||
import type AttributeMeta from "../meta/attribute_meta.js";
|
||||
import type BBranch from "../../becca/entities/bbranch.js";
|
||||
import BBranch from "../../becca/entities/bbranch.js";
|
||||
import type { Response } from "express";
|
||||
import type { NoteMetaFile } from "../meta/note_meta.js";
|
||||
import HtmlExportProvider from "./zip/html.js";
|
||||
import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js";
|
||||
import MarkdownExportProvider from "./zip/markdown.js";
|
||||
import ShareThemeExportProvider from "./zip/share_theme.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
||||
|
||||
export interface AdvancedExportOptions {
|
||||
/**
|
||||
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
||||
*/
|
||||
skipHtmlTemplate?: boolean;
|
||||
|
||||
/**
|
||||
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
||||
*
|
||||
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
||||
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
||||
* @returns a function to rewrite the links in HTML or Markdown notes.
|
||||
*/
|
||||
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
||||
}
|
||||
|
||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||
if (!["html", "markdown"].includes(format)) {
|
||||
throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`);
|
||||
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
|
||||
if (!["html", "markdown", "share"].includes(format)) {
|
||||
throw new ValidationError(`Only 'html', 'markdown' and 'share' allowed as export format, '${format}' given`);
|
||||
}
|
||||
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 } // Sets the compression level.
|
||||
});
|
||||
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
||||
const provider = buildProvider();
|
||||
|
||||
const noteIdToMeta: Record<string, NoteMeta> = {};
|
||||
|
||||
function buildProvider() {
|
||||
const providerData: ZipExportProviderData = {
|
||||
getNoteTargetUrl,
|
||||
archive,
|
||||
branch,
|
||||
rewriteFn
|
||||
};
|
||||
switch (format) {
|
||||
case "html":
|
||||
return new HtmlExportProvider(providerData);
|
||||
case "markdown":
|
||||
return new MarkdownExportProvider(providerData);
|
||||
case "share":
|
||||
return new ShareThemeExportProvider(providerData);
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueFilename(existingFileNames: Record<string, number>, fileName: string) {
|
||||
const lcFileName = fileName.toLowerCase();
|
||||
|
||||
@@ -72,7 +78,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
}
|
||||
|
||||
function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||
function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
|
||||
let fileName = baseFileName.trim();
|
||||
if (!fileName) {
|
||||
fileName = "note";
|
||||
@@ -90,36 +96,14 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
|
||||
let existingExtension = path.extname(fileName).toLowerCase();
|
||||
let newExtension;
|
||||
|
||||
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
||||
// and/or existing detected extensions in the note name
|
||||
if (type === "text" && format === "markdown") {
|
||||
newExtension = "md";
|
||||
} else if (type === "text" && format === "html") {
|
||||
newExtension = "html";
|
||||
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
||||
newExtension = "js";
|
||||
} else if (type === "canvas" || mime === "application/json") {
|
||||
newExtension = "json";
|
||||
} else if (existingExtension.length > 0) {
|
||||
// if the page already has an extension, then we'll just keep it
|
||||
newExtension = null;
|
||||
} else {
|
||||
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
||||
newExtension = "jpg";
|
||||
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
||||
newExtension = "txt";
|
||||
} else {
|
||||
newExtension = mimeTypes.extension(mime) || "dat";
|
||||
}
|
||||
}
|
||||
const newExtension = provider.mapExtension(type, mime, existingExtension, format);
|
||||
|
||||
// if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again
|
||||
if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) {
|
||||
fileName += `.${newExtension}`;
|
||||
}
|
||||
|
||||
|
||||
return getUniqueFilename(existingFileNames, fileName);
|
||||
}
|
||||
|
||||
@@ -145,7 +129,8 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
const notePath = parentMeta.notePath.concat([note.noteId]);
|
||||
|
||||
if (note.noteId in noteIdToMeta) {
|
||||
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === "html" ? "html" : "md"}`);
|
||||
const extension = provider.mapExtension("text", "text/html", "", format);
|
||||
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${extension}`);
|
||||
|
||||
const meta: NoteMeta = {
|
||||
isClone: true,
|
||||
@@ -155,7 +140,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
prefix: branch.prefix,
|
||||
dataFileName: fileName,
|
||||
type: "text", // export will have text description
|
||||
format: format
|
||||
format: (format === "markdown" ? "markdown" : "html")
|
||||
};
|
||||
return meta;
|
||||
}
|
||||
@@ -185,7 +170,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
if (note.type === "text") {
|
||||
meta.format = format;
|
||||
meta.format = (format === "markdown" ? "markdown" : "html");
|
||||
}
|
||||
|
||||
noteIdToMeta[note.noteId] = meta as NoteMeta;
|
||||
@@ -194,10 +179,13 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
note.sortChildren();
|
||||
const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden");
|
||||
|
||||
const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable();
|
||||
let shouldIncludeFile = (!note.isProtected || protectedSessionService.isProtectedSessionAvailable());
|
||||
if (format !== "share") {
|
||||
shouldIncludeFile = shouldIncludeFile && (note.getContent().length > 0 || childBranches.length === 0);
|
||||
}
|
||||
|
||||
// if it's a leaf, then we'll export it even if it's empty
|
||||
if (available && (note.getContent().length > 0 || childBranches.length === 0)) {
|
||||
if (shouldIncludeFile) {
|
||||
meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames);
|
||||
}
|
||||
|
||||
@@ -273,8 +261,6 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
return url;
|
||||
}
|
||||
|
||||
const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks);
|
||||
|
||||
function rewriteLinks(content: string, noteMeta: NoteMeta): string {
|
||||
content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => {
|
||||
const url = getNoteTargetUrl(targetNoteId, noteMeta);
|
||||
@@ -316,53 +302,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
}
|
||||
}
|
||||
|
||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (["html", "markdown"].includes(noteMeta?.format || "")) {
|
||||
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer {
|
||||
const isText = ["html", "markdown"].includes(noteMeta?.format || "");
|
||||
if (isText) {
|
||||
content = content.toString();
|
||||
content = rewriteFn(content, noteMeta);
|
||||
}
|
||||
|
||||
if (noteMeta.format === "html" && typeof content === "string") {
|
||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !zipExportOptions?.skipHtmlTemplate) {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
content = provider.prepareContent(title, content, noteMeta, note, branch);
|
||||
|
||||
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
||||
const htmlTitle = escapeHtml(title);
|
||||
|
||||
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
||||
content = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="${cssUrl}">
|
||||
<base target="_parent">
|
||||
<title data-trilium-title>${htmlTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1 data-trilium-h1>${htmlTitle}</h1>
|
||||
|
||||
<div class="ck-content">${content}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content;
|
||||
} else if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||
let markdownContent = mdService.toMarkdown(content);
|
||||
|
||||
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
||||
markdownContent = `# ${title}\r
|
||||
${markdownContent}`;
|
||||
}
|
||||
|
||||
return markdownContent;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function saveNote(noteMeta: NoteMeta, filePathPrefix: string) {
|
||||
@@ -377,7 +325,7 @@ ${markdownContent}`;
|
||||
|
||||
let content: string | Buffer = `<p>This is a clone of a note. Go to its <a href="${targetUrl}">primary location</a>.</p>`;
|
||||
|
||||
content = prepareContent(noteMeta.title, content, noteMeta);
|
||||
content = prepareContent(noteMeta.title, content, noteMeta, undefined);
|
||||
|
||||
archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
|
||||
|
||||
@@ -393,7 +341,7 @@ ${markdownContent}`;
|
||||
}
|
||||
|
||||
if (noteMeta.dataFileName) {
|
||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta);
|
||||
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note);
|
||||
|
||||
archive.append(content, {
|
||||
name: filePathPrefix + noteMeta.dataFileName,
|
||||
@@ -429,138 +377,21 @@ ${markdownContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
function saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
||||
if (!navigationMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
function saveNavigationInner(meta: NoteMeta) {
|
||||
let html = "<li>";
|
||||
|
||||
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
||||
|
||||
if (meta.dataFileName && meta.noteId) {
|
||||
const targetUrl = getNoteTargetUrl(meta.noteId, rootMeta);
|
||||
|
||||
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
||||
} else {
|
||||
html += escapedTitle;
|
||||
}
|
||||
|
||||
if (meta.children && meta.children.length > 0) {
|
||||
html += "<ul>";
|
||||
|
||||
for (const child of meta.children) {
|
||||
html += saveNavigationInner(child);
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
}
|
||||
|
||||
return `${html}</li>`;
|
||||
}
|
||||
|
||||
const fullHtml = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<ul>${saveNavigationInner(rootMeta)}</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
||||
|
||||
archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
||||
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
||||
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
||||
if (!rootMeta) {
|
||||
throw new Error("Unable to create root meta.");
|
||||
}
|
||||
|
||||
function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
||||
let firstNonEmptyNote;
|
||||
let curMeta = rootMeta;
|
||||
const metaFile: NoteMetaFile = {
|
||||
formatVersion: 2,
|
||||
appVersion: packageInfo.version,
|
||||
files: [rootMeta]
|
||||
};
|
||||
|
||||
if (!indexMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (!firstNonEmptyNote) {
|
||||
if (curMeta.dataFileName && curMeta.noteId) {
|
||||
firstNonEmptyNote = getNoteTargetUrl(curMeta.noteId, rootMeta);
|
||||
}
|
||||
|
||||
if (curMeta.children && curMeta.children.length > 0) {
|
||||
curMeta = curMeta.children[0];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<frameset cols="25%,75%">
|
||||
<frame name="navigation" src="navigation.html">
|
||||
<frame name="detail" src="${firstNonEmptyNote}">
|
||||
</frameset>
|
||||
</html>`;
|
||||
|
||||
archive.append(fullHtml, { name: indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
function saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
||||
if (!cssMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssFile = isDev
|
||||
? path.join(__dirname, "../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
||||
: path.join(getResourceDir(), "ckeditor5-content.css");
|
||||
|
||||
archive.append(fs.readFileSync(cssFile, "utf-8"), { name: cssMeta.dataFileName });
|
||||
}
|
||||
provider.prepareMeta(metaFile);
|
||||
|
||||
try {
|
||||
const existingFileNames: Record<string, number> = format === "html" ? { navigation: 0, index: 1 } : {};
|
||||
const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames);
|
||||
if (!rootMeta) {
|
||||
throw new Error("Unable to create root meta.");
|
||||
}
|
||||
|
||||
const metaFile: NoteMetaFile = {
|
||||
formatVersion: 2,
|
||||
appVersion: packageInfo.version,
|
||||
files: [rootMeta]
|
||||
};
|
||||
|
||||
let navigationMeta: NoteMeta | null = null;
|
||||
let indexMeta: NoteMeta | null = null;
|
||||
let cssMeta: NoteMeta | null = null;
|
||||
|
||||
if (format === "html") {
|
||||
navigationMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "navigation.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(navigationMeta);
|
||||
|
||||
indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(indexMeta);
|
||||
|
||||
cssMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "style.css"
|
||||
};
|
||||
|
||||
metaFile.files.push(cssMeta);
|
||||
}
|
||||
|
||||
for (const noteMeta of Object.values(noteIdToMeta)) {
|
||||
// filter out relations which are not inside this export
|
||||
noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => {
|
||||
@@ -584,34 +415,6 @@ ${markdownContent}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
||||
|
||||
archive.append(metaFileJson, { name: "!!!meta.json" });
|
||||
|
||||
saveNote(rootMeta, "");
|
||||
|
||||
if (format === "html") {
|
||||
if (!navigationMeta || !indexMeta || !cssMeta) {
|
||||
throw new Error("Missing meta.");
|
||||
}
|
||||
|
||||
saveNavigation(rootMeta, navigationMeta);
|
||||
saveIndex(rootMeta, indexMeta);
|
||||
saveCss(rootMeta, cssMeta);
|
||||
}
|
||||
|
||||
const note = branch.getNote();
|
||||
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected() || "note"}.zip`;
|
||||
|
||||
if (setHeaders && "setHeader" in res) {
|
||||
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
}
|
||||
|
||||
archive.pipe(res);
|
||||
await archive.finalize();
|
||||
taskContext.taskSucceeded(null);
|
||||
} catch (e: unknown) {
|
||||
const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`;
|
||||
log.error(message);
|
||||
@@ -623,9 +426,30 @@ ${markdownContent}`;
|
||||
res.status(500).send(message);
|
||||
}
|
||||
}
|
||||
|
||||
const metaFileJson = JSON.stringify(metaFile, null, "\t");
|
||||
|
||||
archive.append(metaFileJson, { name: "!!!meta.json" });
|
||||
|
||||
saveNote(rootMeta, "");
|
||||
|
||||
provider.afterDone(rootMeta);
|
||||
|
||||
const note = branch.getNote();
|
||||
const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`;
|
||||
|
||||
if (setHeaders && "setHeader" in res) {
|
||||
res.setHeader("Content-Disposition", getContentDisposition(zipFileName));
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
}
|
||||
|
||||
archive.pipe(res);
|
||||
await archive.finalize();
|
||||
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||
async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||
const fileOutputStream = fs.createWriteStream(zipFilePath);
|
||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||
|
||||
|
||||
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
89
apps/server/src/services/export/zip/abstract_provider.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Archiver } from "archiver";
|
||||
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||
import mimeTypes from "mime-types";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
|
||||
|
||||
export type ExportFormat = "html" | "markdown" | "share";
|
||||
|
||||
export interface AdvancedExportOptions {
|
||||
/**
|
||||
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <html> template.
|
||||
*/
|
||||
skipHtmlTemplate?: boolean;
|
||||
|
||||
/**
|
||||
* Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type.
|
||||
*
|
||||
* @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it.
|
||||
* @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well.
|
||||
* @returns a function to rewrite the links in HTML or Markdown notes.
|
||||
*/
|
||||
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
|
||||
}
|
||||
|
||||
export interface ZipExportProviderData {
|
||||
branch: BBranch;
|
||||
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||
archive: Archiver;
|
||||
zipExportOptions?: AdvancedExportOptions;
|
||||
rewriteFn: RewriteLinksFn;
|
||||
}
|
||||
|
||||
export abstract class ZipExportProvider {
|
||||
branch: BBranch;
|
||||
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
|
||||
archive: Archiver;
|
||||
zipExportOptions?: AdvancedExportOptions;
|
||||
rewriteFn: RewriteLinksFn;
|
||||
|
||||
constructor(data: ZipExportProviderData) {
|
||||
this.branch = data.branch;
|
||||
this.getNoteTargetUrl = data.getNoteTargetUrl;
|
||||
this.archive = data.archive;
|
||||
this.zipExportOptions = data.zipExportOptions;
|
||||
this.rewriteFn = data.rewriteFn;
|
||||
}
|
||||
|
||||
abstract prepareMeta(metaFile: NoteMetaFile): void;
|
||||
abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer;
|
||||
abstract afterDone(rootMeta: NoteMeta): void;
|
||||
|
||||
/**
|
||||
* Determines the extension of the resulting file for a specific note type.
|
||||
*
|
||||
* @param type the type of the note.
|
||||
* @param mime the mime type of the note.
|
||||
* @param existingExtension the existing extension, including the leading period character.
|
||||
* @param format the format requested for export (e.g. HTML, Markdown).
|
||||
* @returns an extension *without* the leading period character, or `null` to preserve the existing extension instead.
|
||||
*/
|
||||
mapExtension(type: NoteType | null, mime: string, existingExtension: string, format: ExportFormat) {
|
||||
// the following two are handled specifically since we always want to have these extensions no matter the automatic detection
|
||||
// and/or existing detected extensions in the note name
|
||||
if (type === "text" && format === "markdown") {
|
||||
return "md";
|
||||
} else if (type === "text" && format === "html") {
|
||||
return "html";
|
||||
} else if (mime === "application/x-javascript" || mime === "text/javascript") {
|
||||
return "js";
|
||||
} else if (type === "canvas" || mime === "application/json") {
|
||||
return "json";
|
||||
} else if (existingExtension.length > 0) {
|
||||
// if the page already has an extension, then we'll just keep it
|
||||
return null;
|
||||
} else {
|
||||
if (mime?.toLowerCase()?.trim() === "image/jpg") {
|
||||
return "jpg";
|
||||
} else if (mime?.toLowerCase()?.trim() === "text/mermaid") {
|
||||
return "txt";
|
||||
} else {
|
||||
return mimeTypes.extension(mime) || "dat";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
176
apps/server/src/services/export/zip/html.ts
Normal file
176
apps/server/src/services/export/zip/html.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type NoteMeta from "../../meta/note_meta.js";
|
||||
import { escapeHtml, getResourceDir, isDev } from "../../utils";
|
||||
import html from "html";
|
||||
import { ZipExportProvider } from "./abstract_provider.js";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export default class HtmlExportProvider extends ZipExportProvider {
|
||||
|
||||
private navigationMeta: NoteMeta | null = null;
|
||||
private indexMeta: NoteMeta | null = null;
|
||||
private cssMeta: NoteMeta | null = null;
|
||||
|
||||
prepareMeta(metaFile) {
|
||||
this.navigationMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "navigation.html"
|
||||
};
|
||||
metaFile.files.push(this.navigationMeta);
|
||||
|
||||
this.indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
metaFile.files.push(this.indexMeta);
|
||||
|
||||
this.cssMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "style.css"
|
||||
};
|
||||
metaFile.files.push(this.cssMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "html" && typeof content === "string") {
|
||||
if (!content.substr(0, 100).toLowerCase().includes("<html") && !this.zipExportOptions?.skipHtmlTemplate) {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
|
||||
const cssUrl = `${"../".repeat(noteMeta.notePath.length - 1)}style.css`;
|
||||
const htmlTitle = escapeHtml(title);
|
||||
|
||||
// <base> element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809
|
||||
content = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="${cssUrl}">
|
||||
<base target="_parent">
|
||||
<title data-trilium-title>${htmlTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1 data-trilium-h1>${htmlTitle}</h1>
|
||||
|
||||
<div class="ck-content">${content}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
if (content.length < 100_000) {
|
||||
content = html.prettyPrint(content, { indent_size: 2 })
|
||||
}
|
||||
content = this.rewriteFn(content as string, noteMeta);
|
||||
return content;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
afterDone(rootMeta: NoteMeta) {
|
||||
if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) {
|
||||
throw new Error("Missing meta.");
|
||||
}
|
||||
|
||||
this.#saveNavigation(rootMeta, this.navigationMeta);
|
||||
this.#saveIndex(rootMeta, this.indexMeta);
|
||||
this.#saveCss(rootMeta, this.cssMeta);
|
||||
}
|
||||
|
||||
#saveNavigationInner(rootMeta: NoteMeta, meta: NoteMeta) {
|
||||
let html = "<li>";
|
||||
|
||||
const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`);
|
||||
|
||||
if (meta.dataFileName && meta.noteId) {
|
||||
const targetUrl = this.getNoteTargetUrl(meta.noteId, rootMeta);
|
||||
|
||||
html += `<a href="${targetUrl}" target="detail">${escapedTitle}</a>`;
|
||||
} else {
|
||||
html += escapedTitle;
|
||||
}
|
||||
|
||||
if (meta.children && meta.children.length > 0) {
|
||||
html += "<ul>";
|
||||
|
||||
for (const child of meta.children) {
|
||||
html += this.#saveNavigationInner(rootMeta, child);
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
}
|
||||
|
||||
return `${html}</li>`;
|
||||
}
|
||||
|
||||
#saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) {
|
||||
if (!navigationMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullHtml = `<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<ul>${this.#saveNavigationInner(rootMeta, rootMeta)}</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml;
|
||||
|
||||
this.archive.append(prettyHtml, { name: navigationMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
|
||||
let firstNonEmptyNote;
|
||||
let curMeta = rootMeta;
|
||||
|
||||
if (!indexMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (!firstNonEmptyNote) {
|
||||
if (curMeta.dataFileName && curMeta.noteId) {
|
||||
firstNonEmptyNote = this.getNoteTargetUrl(curMeta.noteId, rootMeta);
|
||||
}
|
||||
|
||||
if (curMeta.children && curMeta.children.length > 0) {
|
||||
curMeta = curMeta.children[0];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fullHtml = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<frameset cols="25%,75%">
|
||||
<frame name="navigation" src="navigation.html">
|
||||
<frame name="detail" src="${firstNonEmptyNote}">
|
||||
</frameset>
|
||||
</html>`;
|
||||
|
||||
this.archive.append(fullHtml, { name: indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) {
|
||||
if (!cssMeta.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cssFile = isDev
|
||||
? path.join(__dirname, "../../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
|
||||
: path.join(getResourceDir(), "ckeditor5-content.css");
|
||||
const cssContent = fs.readFileSync(cssFile, "utf-8");
|
||||
this.archive.append(cssContent, { name: cssMeta.dataFileName });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
27
apps/server/src/services/export/zip/markdown.ts
Normal file
27
apps/server/src/services/export/zip/markdown.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import NoteMeta from "../../meta/note_meta"
|
||||
import { ZipExportProvider } from "./abstract_provider.js"
|
||||
import mdService from "../markdown.js";
|
||||
|
||||
export default class MarkdownExportProvider extends ZipExportProvider {
|
||||
|
||||
prepareMeta() { }
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
|
||||
if (noteMeta.format === "markdown" && typeof content === "string") {
|
||||
let markdownContent = mdService.toMarkdown(content);
|
||||
|
||||
if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) {
|
||||
markdownContent = `# ${title}\r
|
||||
${markdownContent}`;
|
||||
}
|
||||
|
||||
markdownContent = this.rewriteFn(markdownContent, noteMeta);
|
||||
return markdownContent;
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
afterDone() { }
|
||||
|
||||
}
|
||||
115
apps/server/src/services/export/zip/share_theme.ts
Normal file
115
apps/server/src/services/export/zip/share_theme.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { join } from "path";
|
||||
import NoteMeta, { NoteMetaFile } from "../../meta/note_meta";
|
||||
import { ExportFormat, ZipExportProvider } from "./abstract_provider.js";
|
||||
import { RESOURCE_DIR } from "../../resource_dir";
|
||||
import { getResourceDir, isDev } from "../../utils";
|
||||
import fs, { readdirSync } from "fs";
|
||||
import { renderNoteForExport } from "../../../share/content_renderer";
|
||||
import type BNote from "../../../becca/entities/bnote.js";
|
||||
import type BBranch from "../../../becca/entities/bbranch.js";
|
||||
import { getShareThemeAssetDir } from "../../../routes/assets";
|
||||
|
||||
const shareThemeAssetDir = getShareThemeAssetDir();
|
||||
|
||||
export default class ShareThemeExportProvider extends ZipExportProvider {
|
||||
|
||||
private assetsMeta: NoteMeta[] = [];
|
||||
private indexMeta: NoteMeta | null = null;
|
||||
|
||||
prepareMeta(metaFile: NoteMetaFile): void {
|
||||
|
||||
const assets = [
|
||||
"icon-color.svg"
|
||||
];
|
||||
|
||||
for (const file of readdirSync(shareThemeAssetDir)) {
|
||||
assets.push(`assets/${file}`);
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
const assetMeta = {
|
||||
noImport: true,
|
||||
dataFileName: asset
|
||||
};
|
||||
this.assetsMeta.push(assetMeta);
|
||||
metaFile.files.push(assetMeta);
|
||||
}
|
||||
|
||||
this.indexMeta = {
|
||||
noImport: true,
|
||||
dataFileName: "index.html"
|
||||
};
|
||||
|
||||
metaFile.files.push(this.indexMeta);
|
||||
}
|
||||
|
||||
prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer {
|
||||
if (!noteMeta?.notePath?.length) {
|
||||
throw new Error("Missing note path.");
|
||||
}
|
||||
const basePath = "../".repeat(noteMeta.notePath.length - 1);
|
||||
|
||||
if (note) {
|
||||
content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1));
|
||||
if (typeof content === "string") {
|
||||
content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, (match, id) => {
|
||||
if (match.includes("/assets/")) return match;
|
||||
return `href="#root/${id}"`;
|
||||
});
|
||||
content = this.rewriteFn(content, noteMeta);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
afterDone(rootMeta: NoteMeta): void {
|
||||
this.#saveAssets(rootMeta, this.assetsMeta);
|
||||
this.#saveIndex(rootMeta);
|
||||
}
|
||||
|
||||
mapExtension(type: string | null, mime: string, existingExtension: string, format: ExportFormat): string | null {
|
||||
if (mime.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "html";
|
||||
}
|
||||
|
||||
#saveIndex(rootMeta: NoteMeta) {
|
||||
if (!this.indexMeta?.dataFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const note = this.branch.getNote();
|
||||
const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch);
|
||||
this.archive.append(fullHtml, { name: this.indexMeta.dataFileName });
|
||||
}
|
||||
|
||||
#saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) {
|
||||
for (const assetMeta of assetsMeta) {
|
||||
if (!assetMeta.dataFileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cssContent = getShareThemeAssets(assetMeta.dataFileName);
|
||||
this.archive.append(cssContent, { name: assetMeta.dataFileName });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getShareThemeAssets(nameWithExtension: string) {
|
||||
let path: string | undefined;
|
||||
if (nameWithExtension === "icon-color.svg") {
|
||||
path = join(RESOURCE_DIR, "images", nameWithExtension);
|
||||
} else if (nameWithExtension.startsWith("assets")) {
|
||||
path = join(shareThemeAssetDir, nameWithExtension.replace(/^assets\//, ""));
|
||||
} else if (isDev) {
|
||||
path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension);
|
||||
} else {
|
||||
path = join(getResourceDir(), "public", "src", nameWithExtension);
|
||||
}
|
||||
|
||||
return fs.readFileSync(path);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -66,7 +66,7 @@ sqlInit.dbReady.then(() => {
|
||||
);
|
||||
}
|
||||
|
||||
setInterval(() => checkProtectedSessionExpiration(), 1);
|
||||
setInterval(() => checkProtectedSessionExpiration(), 30000);
|
||||
});
|
||||
|
||||
function checkProtectedSessionExpiration() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { parse, HTMLElement, TextNode } from "node-html-parser";
|
||||
import { parse, HTMLElement, TextNode, Options } from "node-html-parser";
|
||||
import shaca from "./shaca/shaca.js";
|
||||
import assetPath from "../services/asset_path.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import shareRoot from "./share_root.js";
|
||||
import escapeHtml from "escape-html";
|
||||
import type SNote from "./shaca/entities/snote.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import type BBranch from "../becca/entities/bbranch.js";
|
||||
import { t } from "i18next";
|
||||
import SBranch from "./shaca/entities/sbranch.js";
|
||||
import options from "../services/options.js";
|
||||
import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import ejs from "ejs";
|
||||
import log from "../services/log.js";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { highlightAuto } from "@triliumnext/highlightjs";
|
||||
|
||||
const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`;
|
||||
const templateCache: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Represents the output of the content renderer.
|
||||
@@ -16,7 +29,192 @@ export interface Result {
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
export function getContent(note: SNote) {
|
||||
interface Subroot {
|
||||
note?: SNote | BNote;
|
||||
branch?: SBranch | BBranch
|
||||
}
|
||||
|
||||
function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
|
||||
if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
// share root itself is not shared
|
||||
return {};
|
||||
}
|
||||
|
||||
// every path leads to share root, but which one to choose?
|
||||
// for the sake of simplicity, URLs are not note paths
|
||||
const parentBranch = note.getParentBranches()[0];
|
||||
|
||||
if (note instanceof BNote) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
}
|
||||
}
|
||||
|
||||
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
};
|
||||
}
|
||||
|
||||
return getSharedSubTreeRoot(parentBranch.getParentNote());
|
||||
}
|
||||
|
||||
export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[]) {
|
||||
const subRoot: Subroot = {
|
||||
branch: parentBranch,
|
||||
note: parentBranch.getNote()
|
||||
};
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
rootNoteId: parentBranch.noteId,
|
||||
cssToLoad: [
|
||||
`${basePath}assets/styles.css`,
|
||||
`${basePath}assets/scripts.css`,
|
||||
],
|
||||
jsToLoad: [
|
||||
`${basePath}assets/scripts.js`
|
||||
],
|
||||
logoUrl: `${basePath}icon-color.svg`,
|
||||
ancestors
|
||||
});
|
||||
}
|
||||
|
||||
export function renderNoteContent(note: SNote) {
|
||||
const subRoot = getSharedSubTreeRoot(note);
|
||||
|
||||
const ancestors: string[] = [];
|
||||
let notePointer = note;
|
||||
while (notePointer.parents[0]?.noteId !== subRoot.note?.noteId) {
|
||||
const pointerParent = notePointer.parents[0];
|
||||
if (!pointerParent) {
|
||||
break;
|
||||
}
|
||||
ancestors.push(pointerParent.noteId);
|
||||
notePointer = pointerParent;
|
||||
}
|
||||
|
||||
// Determine CSS to load.
|
||||
const cssToLoad: string[] = [];
|
||||
if (!note.isLabelTruthy("shareOmitDefaultCss")) {
|
||||
cssToLoad.push(`assets/styles.css`);
|
||||
cssToLoad.push(`assets/scripts.css`);
|
||||
}
|
||||
for (const cssRelation of note.getRelations("shareCss")) {
|
||||
cssToLoad.push(`api/notes/${cssRelation.value}/download`);
|
||||
}
|
||||
|
||||
// Determine JS to load.
|
||||
const jsToLoad: string[] = [
|
||||
"assets/scripts.js"
|
||||
];
|
||||
for (const jsRelation of note.getRelations("shareJs")) {
|
||||
jsToLoad.push(`api/notes/${jsRelation.value}/download`);
|
||||
}
|
||||
|
||||
const customLogoId = note.getRelation("shareLogo")?.value;
|
||||
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`;
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
rootNoteId: "_share",
|
||||
cssToLoad,
|
||||
jsToLoad,
|
||||
logoUrl,
|
||||
ancestors
|
||||
});
|
||||
}
|
||||
|
||||
interface RenderArgs {
|
||||
subRoot: Subroot;
|
||||
rootNoteId: string;
|
||||
cssToLoad: string[];
|
||||
jsToLoad: string[];
|
||||
logoUrl: string;
|
||||
ancestors: string[];
|
||||
}
|
||||
|
||||
function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) {
|
||||
const { header, content, isEmpty } = getContent(note);
|
||||
const showLoginInShareTheme = options.getOption("showLoginInShareTheme");
|
||||
const opts = {
|
||||
note,
|
||||
header,
|
||||
content,
|
||||
isEmpty,
|
||||
assetPath: shareAdjustedAssetPath,
|
||||
assetUrlFragment,
|
||||
showLoginInShareTheme,
|
||||
t,
|
||||
isDev,
|
||||
utils,
|
||||
...renderArgs
|
||||
};
|
||||
|
||||
// Check if the user has their own template.
|
||||
if (note.hasRelation("shareTemplate")) {
|
||||
// Get the template note and content
|
||||
const templateId = note.getRelation("shareTemplate")?.value;
|
||||
const templateNote = templateId && shaca.getNote(templateId);
|
||||
|
||||
// Make sure the note type is correct
|
||||
if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") {
|
||||
// EJS caches the result of this so we don't need to pre-cache
|
||||
const includer = (path: string) => {
|
||||
const childNote = templateNote.children.find((n) => path === n.title);
|
||||
if (!childNote) throw new Error(`Unable to find child note: ${path}.`);
|
||||
if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type.");
|
||||
|
||||
const template = childNote.getContent();
|
||||
if (typeof template !== "string") throw new Error("Invalid template content type.");
|
||||
|
||||
return { template };
|
||||
};
|
||||
|
||||
// Try to render user's template, w/ fallback to default view
|
||||
try {
|
||||
const content = templateNote.getContent();
|
||||
if (typeof content === "string") {
|
||||
return ejs.render(content, opts, { includer });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
|
||||
log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render with the default view otherwise.
|
||||
const templatePath = getDefaultTemplatePath("page");
|
||||
return ejs.render(readTemplate(templatePath), opts, {
|
||||
includer: (path) => {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
return { template: readTemplate(getDefaultTemplatePath(path)) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultTemplatePath(template: string) {
|
||||
// Path is relative to apps/server/dist/assets/views
|
||||
return process.env.NODE_ENV === "development"
|
||||
? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`)
|
||||
: join(getResourceDir(), `share-theme/templates/${template}.ejs`);
|
||||
}
|
||||
|
||||
function readTemplate(path: string) {
|
||||
const cachedTemplate = templateCache.get(path);
|
||||
if (cachedTemplate) {
|
||||
return cachedTemplate;
|
||||
}
|
||||
|
||||
const templateString = readFileSync(path, "utf-8");
|
||||
templateCache.set(path, templateString);
|
||||
return templateString;
|
||||
}
|
||||
|
||||
export function getContent(note: SNote | BNote) {
|
||||
if (note.isProtected) {
|
||||
return {
|
||||
header: "",
|
||||
@@ -65,9 +263,12 @@ function renderIndex(result: Result) {
|
||||
result.content += "</ul>";
|
||||
}
|
||||
|
||||
function renderText(result: Result, note: SNote) {
|
||||
function renderText(result: Result, note: SNote | BNote) {
|
||||
if (typeof result.content !== "string") return;
|
||||
const document = parse(result.content || "");
|
||||
const parseOpts: Partial<Options> = {
|
||||
blockTextElements: {}
|
||||
}
|
||||
const document = parse(result.content || "", parseOpts);
|
||||
|
||||
// Process include notes.
|
||||
for (const includeNoteEl of document.querySelectorAll("section.include-note")) {
|
||||
@@ -80,7 +281,7 @@ function renderText(result: Result, note: SNote) {
|
||||
const includedResult = getContent(note);
|
||||
if (typeof includedResult.content !== "string") continue;
|
||||
|
||||
const includedDocument = parse(includedResult.content).childNodes;
|
||||
const includedDocument = parse(includedResult.content, parseOpts).childNodes;
|
||||
if (includedDocument) {
|
||||
includeNoteEl.replaceWith(...includedDocument);
|
||||
}
|
||||
@@ -89,6 +290,7 @@ function renderText(result: Result, note: SNote) {
|
||||
result.isEmpty = document.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0;
|
||||
|
||||
if (!result.isEmpty) {
|
||||
// Process attachment links.
|
||||
for (const linkEl of document.querySelectorAll("a")) {
|
||||
const href = linkEl.getAttribute("href");
|
||||
|
||||
@@ -102,21 +304,15 @@ function renderText(result: Result, note: SNote) {
|
||||
}
|
||||
}
|
||||
|
||||
result.content = document.innerHTML ?? "";
|
||||
|
||||
if (result.content.includes(`<span class="math-tex">`)) {
|
||||
result.header += `
|
||||
<script src="../${assetPath}/node_modules/katex/dist/katex.min.js"></script>
|
||||
<link rel="stylesheet" href="../${assetPath}/node_modules/katex/dist/katex.min.css">
|
||||
<script src="../${assetPath}/node_modules/katex/dist/contrib/auto-render.min.js"></script>
|
||||
<script src="../${assetPath}/node_modules/katex/dist/contrib/mhchem.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
renderMathInElement(document.getElementById('content'));
|
||||
});
|
||||
</script>`;
|
||||
// Apply syntax highlight.
|
||||
for (const codeEl of document.querySelectorAll("pre code")) {
|
||||
const highlightResult = highlightAuto(codeEl.innerText);
|
||||
codeEl.innerHTML = highlightResult.value;
|
||||
codeEl.classList.add("hljs");
|
||||
}
|
||||
|
||||
result.content = document.innerHTML ?? "";
|
||||
|
||||
if (note.hasLabel("shareIndex")) {
|
||||
renderIndex(result);
|
||||
}
|
||||
@@ -174,7 +370,7 @@ export function renderCode(result: Result) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderMermaid(result: Result, note: SNote) {
|
||||
function renderMermaid(result: Result, note: SNote | BNote) {
|
||||
if (typeof result.content !== "string") {
|
||||
return;
|
||||
}
|
||||
@@ -188,11 +384,11 @@ function renderMermaid(result: Result, note: SNote) {
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderImage(result: Result, note: SNote) {
|
||||
function renderImage(result: Result, note: SNote | BNote) {
|
||||
result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">`;
|
||||
}
|
||||
|
||||
function renderFile(note: SNote, result: Result) {
|
||||
function renderFile(note: SNote | BNote, result: Result) {
|
||||
if (note.mime === "application/pdf") {
|
||||
result.content = `<iframe class="pdf-view" src="api/notes/${note.noteId}/view"></iframe>`;
|
||||
} else {
|
||||
|
||||
@@ -4,41 +4,12 @@ import type { Request, Response, Router } from "express";
|
||||
|
||||
import shaca from "./shaca/shaca.js";
|
||||
import shacaLoader from "./shaca/shaca_loader.js";
|
||||
import shareRoot from "./share_root.js";
|
||||
import contentRenderer from "./content_renderer.js";
|
||||
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
|
||||
import appPath from "../services/app_path.js";
|
||||
import searchService from "../services/search/services/search.js";
|
||||
import SearchContext from "../services/search/search_context.js";
|
||||
import log from "../services/log.js";
|
||||
import type SNote from "./shaca/entities/snote.js";
|
||||
import type SBranch from "./shaca/entities/sbranch.js";
|
||||
import type SAttachment from "./shaca/entities/sattachment.js";
|
||||
import utils, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import options from "../services/options.js";
|
||||
import { t } from "i18next";
|
||||
import ejs from "ejs";
|
||||
import { join } from "path";
|
||||
|
||||
function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } {
|
||||
if (note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
// share root itself is not shared
|
||||
return {};
|
||||
}
|
||||
|
||||
// every path leads to share root, but which one to choose?
|
||||
// for the sake of simplicity, URLs are not note paths
|
||||
const parentBranch = note.getParentBranches()[0];
|
||||
|
||||
if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) {
|
||||
return {
|
||||
note,
|
||||
branch: parentBranch
|
||||
};
|
||||
}
|
||||
|
||||
return getSharedSubTreeRoot(parentBranch.getParentNote());
|
||||
}
|
||||
import { renderNoteContent } from "./content_renderer.js";
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
function addNoIndexHeader(note: SNote, res: Response) {
|
||||
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
|
||||
@@ -109,8 +80,7 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
|
||||
let svgString = "<svg/>";
|
||||
const attachment = image.getAttachmentByTitle(attachmentName);
|
||||
if (!attachment) {
|
||||
res.status(404);
|
||||
renderDefault(res, "404");
|
||||
|
||||
return;
|
||||
}
|
||||
const content = attachment.getContent();
|
||||
@@ -138,12 +108,19 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
function render404(res: Response) {
|
||||
res.status(404);
|
||||
const shareThemePath = `../../share-theme/templates/404.ejs`;
|
||||
res.render(shareThemePath);
|
||||
}
|
||||
|
||||
function register(router: Router) {
|
||||
|
||||
function renderNote(note: SNote, req: Request, res: Response) {
|
||||
if (!note) {
|
||||
console.log("Unable to find note ", note);
|
||||
res.status(404);
|
||||
renderDefault(res, "404");
|
||||
render404(res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,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
|
||||
};
|
||||
|
||||
6
apps/server/src/types.d.ts
vendored
6
apps/server/src/types.d.ts
vendored
@@ -38,3 +38,9 @@ declare module "@triliumnext/share-theme/styles.css" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.css' {}
|
||||
declare module '*?raw' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user