Compare commits

..

1 Commits

Author SHA1 Message Date
Elian Doran
0ec2160eff Standalone import (#9204) 2026-03-27 21:52:02 +02:00
42 changed files with 462 additions and 607 deletions

View File

@@ -1,18 +0,0 @@
import { type ExportFormat, type ZipExportProviderData, ZipExportProvider } from "@triliumnext/core";
import contentCss from "@triliumnext/ckeditor5/src/theme/ck-content.css?raw";
export async function standaloneZipExportProviderFactory(format: ExportFormat, data: ZipExportProviderData): Promise<ZipExportProvider> {
switch (format) {
case "html": {
const { default: HtmlExportProvider } = await import("@triliumnext/core/src/services/export/zip/html.js");
return new HtmlExportProvider(data, { contentCss });
}
case "markdown": {
const { default: MarkdownExportProvider } = await import("@triliumnext/core/src/services/export/zip/markdown.js");
return new MarkdownExportProvider(data);
}
default:
throw new Error(`Unsupported export format: '${format}'`);
}
}

View File

@@ -1,59 +1,7 @@
import type { FileStream, ZipArchive, ZipEntry, ZipProvider } from "@triliumnext/core/src/services/zip_provider.js";
import { strToU8, unzip, zipSync } from "fflate";
type ZipOutput = {
send?: (body: unknown) => unknown;
write?: (chunk: Uint8Array | string) => unknown;
end?: (chunk?: Uint8Array | string) => unknown;
};
class BrowserZipArchive implements ZipArchive {
readonly #entries: Record<string, Uint8Array> = {};
#destination: ZipOutput | null = null;
append(content: string | Uint8Array, options: { name: string }) {
this.#entries[options.name] = typeof content === "string" ? strToU8(content) : content;
}
pipe(destination: unknown) {
this.#destination = destination as ZipOutput;
}
async finalize(): Promise<void> {
if (!this.#destination) {
throw new Error("ZIP output destination not set.");
}
const content = zipSync(this.#entries, { level: 9 });
if (typeof this.#destination.send === "function") {
this.#destination.send(content);
return;
}
if (typeof this.#destination.end === "function") {
if (typeof this.#destination.write === "function") {
this.#destination.write(content);
this.#destination.end();
} else {
this.#destination.end(content);
}
return;
}
throw new Error("Unsupported ZIP output destination.");
}
}
import type { ZipEntry, ZipProvider } from "@triliumnext/core/src/services/import/zip_provider.js";
import { unzip } from "fflate";
export default class BrowserZipProvider implements ZipProvider {
createZipArchive(): ZipArchive {
return new BrowserZipArchive();
}
createFileStream(_filePath: string): FileStream {
throw new Error("File stream creation is not supported in the browser.");
}
readZipFile(
buffer: Uint8Array,
processEntry: (entry: ZipEntry, readContent: () => Promise<Uint8Array>) => Promise<void>

View File

@@ -157,7 +157,6 @@ async function initialize(): Promise<void> {
executionContext: new BrowserExecutionContext(),
crypto: new BrowserCryptoProvider(),
zip: new BrowserZipProvider(),
zipExportProviderFactory: (await import("./lightweight/zip_export_provider_factory.js")).standaloneZipExportProviderFactory,
messaging: messagingProvider!,
request: new FetchRequestProvider(),
platform: new StandalonePlatformProvider(queryString),

View File

@@ -162,13 +162,6 @@ self.addEventListener("fetch", (event) => {
// Only handle same-origin
if (url.origin !== self.location.origin) return;
// API-ish: local-first via bridge (must be checked before navigate handling,
// because export triggers a navigation to an /api/ URL)
if (isLocalFirst(url)) {
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
return;
}
// HTML files: network-first to ensure updates are reflected immediately
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
event.respondWith(networkFirst(event.request));
@@ -176,11 +169,17 @@ self.addEventListener("fetch", (event) => {
}
// Static assets: cache-first for performance
if (event.request.method === "GET") {
if (event.request.method === "GET" && !isLocalFirst(url)) {
event.respondWith(cacheFirst(event.request));
return;
}
// API-ish: local-first via bridge
if (isLocalFirst(url)) {
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
return;
}
// Default
event.respondWith(fetch(event.request));
});

View File

@@ -135,7 +135,6 @@ async function main() {
},
crypto: new NodejsCryptoProvider(),
zip: new NodejsZipProvider(),
zipExportProviderFactory: (await import("@triliumnext/server/src/services/export/zip/factory.js")).serverZipExportProviderFactory,
request: new NodeRequestProvider(),
executionContext: new ClsHookedExecutionContext(),
messaging: new WebSocketMessagingProvider(),

View File

@@ -54,8 +54,8 @@ async function registerHandlers() {
}
async function exportData() {
const { zipExportService } = (await import("@triliumnext/core"));
await zipExportService.exportToZipFile("root", "html", DEMO_ZIP_PATH);
const { exportToZipFile } = (await import("@triliumnext/server/src/services/export/zip.js")).default;
await exportToZipFile("root", "html", DEMO_ZIP_PATH);
}
main();

View File

@@ -1,6 +1,7 @@
import debounce from "@triliumnext/client/src/services/debounce.js";
import type { AdvancedExportOptions, ExportFormat } from "@triliumnext/core";
import cls from "@triliumnext/server/src/services/cls.js";
import type { AdvancedExportOptions, ExportFormat } from "@triliumnext/server/src/services/export/zip/abstract_provider.js";
import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js";
import { parseNoteMetaFile } from "@triliumnext/server/src/services/in_app_help.js";
import type { NoteMetaFile } from "@triliumnext/server/src/services/meta/note_meta.js";
import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js";
@@ -10,7 +11,7 @@ import yaml from "js-yaml";
import path from "path";
import packageJson from "../package.json" with { type: "json" };
import { extractZip, importData, startElectron } from "./utils.js";
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
interface NoteMapping {
rootNoteId: string;
@@ -152,7 +153,7 @@ async function exportData(noteId: string, format: ExportFormat, outputPath: stri
await fsExtra.mkdir(outputPath);
// First export as zip.
const { zipExportService } = (await import("@triliumnext/core"));
const { exportToZipFile } = (await import("@triliumnext/server/src/services/export/zip.js")).default;
const exportOpts: AdvancedExportOptions = {};
if (format === "html") {
@@ -204,7 +205,7 @@ async function exportData(noteId: string, format: ExportFormat, outputPath: stri
};
}
await zipExportService.exportToZipFile(noteId, format, zipFilePath, exportOpts);
await exportToZipFile(noteId, format, zipFilePath, exportOpts);
await extractZip(zipFilePath, outputPath, ignoredFiles);
} finally {
if (await fsExtra.exists(zipFilePath)) {

View File

@@ -41,6 +41,7 @@
"@triliumnext/core": "workspace:*",
"@triliumnext/express-partial-content": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/turndown-plugin-gfm": "workspace:*",
"@types/archiver": "7.0.0",
"@types/better-sqlite3": "7.6.13",
"@types/cls-hooked": "4.3.9",

View File

@@ -2,7 +2,6 @@ import { beforeAll } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
import { initializeCore } from "@triliumnext/core";
import { serverZipExportProviderFactory } from "../src/services/export/zip/factory.js";
import ClsHookedExecutionContext from "../src/cls_provider.js";
import NodejsCryptoProvider from "../src/crypto_provider.js";
import NodejsZipProvider from "../src/zip_provider.js";
@@ -30,7 +29,6 @@ beforeAll(async () => {
},
crypto: new NodejsCryptoProvider(),
zip: new NodejsZipProvider(),
zipExportProviderFactory: serverZipExportProviderFactory,
executionContext: new ClsHookedExecutionContext(),
schema: readFileSync(require.resolve("@triliumnext/core/src/assets/schema.sql"), "utf-8"),
platform: new ServerPlatformProvider(),

View File

@@ -1,8 +1,10 @@
import { type ExportFormat, NoteParams, SearchParams, zipExportService, zipImportService } from "@triliumnext/core";
import { NoteParams, SearchParams, zipImportService } from "@triliumnext/core";
import type { Request, Router } from "express";
import type { ParsedQs } from "qs";
import becca from "../becca/becca.js";
import zipExportService from "../services/export/zip.js";
import type { ExportFormat } from "../services/export/zip/abstract_provider.js";
import noteService from "../services/notes.js";
import SearchContext from "../services/search/search_context.js";
import searchService from "../services/search/services/search.js";

View File

@@ -3,7 +3,7 @@
* are loaded later and will result in an empty string.
*/
import { getLog, initializeCore, sql_init } from "@triliumnext/core";
import { getLog,initializeCore, sql_init } from "@triliumnext/core";
import fs from "fs";
import { t } from "i18next";
import path from "path";
@@ -53,7 +53,6 @@ async function startApplication() {
},
crypto: new NodejsCryptoProvider(),
zip: new NodejsZipProvider(),
zipExportProviderFactory: (await import("./services/export/zip/factory.js")).serverZipExportProviderFactory,
request: new NodeRequestProvider(),
executionContext: new ClsHookedExecutionContext(),
messaging: new WebSocketMessagingProvider(),

View File

@@ -1,21 +1,21 @@
import { NotFoundError, ValidationError } from "@triliumnext/core";
import type { Request, Response } from "express";
import becca from "../../becca/becca.js";
import opmlExportService from "../../services/export/opml.js";
import singleExportService from "../../services/export/single.js";
import zipExportService from "../../services/export/zip.js";
import { getLog } from "../../services/log.js";
import log from "../../services/log.js";
import TaskContext from "../../services/task_context.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils/index.js";
import { NotFoundError, ValidationError } from "../../errors.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
async function exportBranch(req: Request<{ branchId: string; type: string; format: string; version: string; taskId: string }>, res: Response) {
function exportBranch(req: Request<{ branchId: string; type: string; format: string; version: string; taskId: string }>, res: Response) {
const { branchId, type, format, version, taskId } = req.params;
const branch = becca.getBranch(branchId);
if (!branch) {
const message = `Cannot export branch '${branchId}' since it does not exist.`;
getLog().error(message);
log.error(message);
res.setHeader("Content-Type", "text/plain").status(500).send(message);
return;
@@ -25,7 +25,7 @@ async function exportBranch(req: Request<{ branchId: string; type: string; forma
try {
if (type === "subtree" && (format === "html" || format === "markdown" || format === "share")) {
await zipExportService.exportToZip(taskContext, branch, format, res);
zipExportService.exportToZip(taskContext, branch, format, res);
} else if (type === "single") {
if (format !== "html" && format !== "markdown") {
throw new ValidationError("Invalid export type.");
@@ -41,7 +41,7 @@ async function exportBranch(req: Request<{ branchId: string; type: string; forma
const message = `Export failed with following error: '${errMessage}'. More details might be in the logs.`;
taskContext.reportError(message);
getLog().error(errMessage + errStack);
log.error(errMessage + errStack);
res.setHeader("Content-Type", "text/plain").status(500).send(message);
}

View File

@@ -0,0 +1,30 @@
import { RenderMarkdownResponse, ToMarkdownResponse } from "@triliumnext/commons";
import { markdownImportService } from "@triliumnext/core";
import type { Request } from "express";
import markdown from "../../services/export/markdown.js";
function renderMarkdown(req: Request) {
const { markdownContent } = req.body;
if (!markdownContent || typeof markdownContent !== 'string') {
throw new Error('markdownContent parameter is required and must be a string');
}
return {
htmlContent: markdownImportService.renderToHtml(markdownContent, "")
} satisfies RenderMarkdownResponse;
}
function toMarkdown(req: Request) {
const { htmlContent } = req.body;
if (!htmlContent || typeof htmlContent !== 'string') {
throw new Error('htmlContent parameter is required and must be a string');
}
return {
markdownContent: markdown.toMarkdown(htmlContent)
} satisfies ToMarkdownResponse;
}
export default {
renderMarkdown,
toMarkdown
};

View File

@@ -22,10 +22,12 @@ import backendLogRoute from "./api/backend_log.js";
import clipperRoute from "./api/clipper.js";
import databaseRoute from "./api/database.js";
import etapiTokensApiRoutes from "./api/etapi_tokens.js";
import exportRoute from "./api/export.js";
import filesRoute from "./api/files.js";
import fontsRoute from "./api/fonts.js";
import loginApiRoute from "./api/login.js";
import metricsRoute from "./api/metrics.js";
import otherRoute from "./api/other.js";
import passwordApiRoute from "./api/password.js";
import recoveryCodes from './api/recovery_codes.js';
import scriptRoute from "./api/script.js";
@@ -128,6 +130,10 @@ function register(app: express.Application) {
// TODO: Re-enable once we support route()
// route(GET, "/api/revisions/:revisionId/download", [auth.checkApiAuthOrElectron], revisionsApiRoute.downloadRevision);
route(GET, "/api/branches/:branchId/export/:type/:format/:version/:taskId", [auth.checkApiAuthOrElectron], exportRoute.exportBranch);
// :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename
apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword);
apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword);
@@ -192,6 +198,8 @@ function register(app: express.Application) {
asyncApiRoute(GET, "/api/backend-log", backendLogRoute.getBackendLog);
route(GET, "/api/fonts", [auth.checkApiAuthOrElectron], fontsRoute.getFontCss);
apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown);
apiRoute(PST, "/api/other/to-markdown", otherRoute.toMarkdown);
shareRoutes.register(router);

View File

@@ -1,5 +1,5 @@
import { type AttributeRow, dayjs, formatLogMessage } from "@triliumnext/commons";
import { type AbstractBeccaEntity, Becca, branches as branchService, NoteParams, SearchContext, sync_mutex as syncMutex, zipExportService } from "@triliumnext/core";
import { type AbstractBeccaEntity, Becca, branches as branchService, NoteParams, SearchContext, sync_mutex as syncMutex } from "@triliumnext/core";
import axios from "axios";
import * as cheerio from "cheerio";
import xml2js from "xml2js";
@@ -19,6 +19,7 @@ import backupService from "./backup.js";
import cloningService from "./cloning.js";
import config from "./config.js";
import dateNoteService from "./date_notes.js";
import exportService from "./export/zip.js";
import log from "./log.js";
import noteService from "./notes.js";
import optionsService from "./options.js";
@@ -661,7 +662,7 @@ function BackendScriptApi(this: Api, currentNote: BNote, apiParams: ApiParams) {
return { note: launcherNote };
};
this.exportSubtreeToZipFile = async (noteId, format, zipFilePath) => await zipExportService.exportToZipFile(noteId, format, zipFilePath);
this.exportSubtreeToZipFile = async (noteId, format, zipFilePath) => await exportService.exportToZipFile(noteId, format, zipFilePath);
this.runOnFrontend = async (_script, params = []) => {
let script: string;

View File

@@ -0,0 +1,284 @@
import { gfm } from "@triliumnext/turndown-plugin-gfm";
import Turnish, { type Rule } from "turnish";
let instance: Turnish | null = null;
// TODO: Move this to a dedicated file someday.
export const ADMONITION_TYPE_MAPPINGS: Record<string, string> = {
note: "NOTE",
tip: "TIP",
important: "IMPORTANT",
caution: "CAUTION",
warning: "WARNING"
};
export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPE_MAPPINGS.note;
const fencedCodeBlockFilter: Rule = {
filter (node, options) {
return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild !== null && node.firstChild.nodeName === "CODE";
},
replacement (content, node, options) {
if (!node.firstChild || !("getAttribute" in node.firstChild) || typeof node.firstChild.getAttribute !== "function") {
return content;
}
const className = node.firstChild.getAttribute("class") || "";
const language = rewriteLanguageTag((className.match(/language-(\S+)/) || [null, ""])[1]);
return `\n\n${options.fence}${language}\n${node.firstChild.textContent}\n${options.fence}\n\n`;
}
};
function toMarkdown(content: string) {
if (instance === null) {
instance = new Turnish({
headingStyle: "atx",
bulletListMarker: "*",
emDelimiter: "_",
codeBlockStyle: "fenced",
blankReplacement(_content, node) {
if (node.nodeName === "SECTION" && node.classList.contains("include-note")) {
return node.outerHTML;
}
// Original implementation as per https://github.com/mixmark-io/turndown/blob/master/src/turndown.js.
return ("isBlock" in node && node.isBlock) ? '\n\n' : '';
},
});
// Filter is heavily based on: https://github.com/mixmark-io/turndown/issues/274#issuecomment-458730974
instance.addRule("fencedCodeBlock", fencedCodeBlockFilter);
instance.addRule("img", buildImageFilter());
instance.addRule("admonition", buildAdmonitionFilter());
instance.addRule("inlineLink", buildInlineLinkFilter());
instance.addRule("figure", buildFigureFilter());
instance.addRule("math", buildMathFilter());
instance.addRule("li", buildListItemFilter());
instance.use(gfm);
instance.keep([ "kbd", "sup", "sub" ]);
}
return instance.render(content);
}
function rewriteLanguageTag(source: string) {
if (!source) {
return source;
}
switch (source) {
case "text-x-trilium-auto":
return "";
case "application-javascript-env-frontend":
case "application-javascript-env-backend":
return "javascript";
case "text-x-nginx-conf":
return "nginx";
default:
return source.split("-").at(-1);
}
}
// TODO: Remove once upstream delivers a fix for https://github.com/mixmark-io/turndown/issues/467.
function buildImageFilter() {
const ESCAPE_PATTERNS = {
before: /([\\*`[\]_]|(?:^[-+>])|(?:^~~~)|(?:^#{1-6}))/g,
after: /((?:^\d+(?=\.)))/
};
const escapePattern = new RegExp(`(?:${ESCAPE_PATTERNS.before.source}|${ESCAPE_PATTERNS.after.source})`, 'g');
function escapeMarkdown (content: string) {
return content.replace(escapePattern, (match, before, after) => {
return before ? `\\${before}` : `${after}\\`;
});
}
function escapeLinkDestination(destination: string) {
return destination
.replace(/([()])/g, '\\$1')
.replace(/ /g, "%20");
}
function escapeLinkTitle (title: string) {
return title.replace(/"/g, '\\"');
}
const imageFilter: Rule = {
filter: "img",
replacement(content, _node) {
const node = _node as HTMLElement;
// Preserve image verbatim if it has a width or height attribute.
if (node.hasAttribute("width") || node.hasAttribute("height")) {
return node.outerHTML;
}
// TODO: Deduplicate with upstream.
const untypedNode = (node as any);
const alt = escapeMarkdown(cleanAttribute(untypedNode.getAttribute('alt')));
const src = escapeLinkDestination(untypedNode.getAttribute('src') || '');
const title = cleanAttribute(untypedNode.getAttribute('title'));
const titlePart = title ? ` "${escapeLinkTitle(title)}"` : '';
return src ? `![${alt}](${src}${titlePart})` : '';
}
};
return imageFilter;
}
function buildAdmonitionFilter() {
function parseAdmonitionType(_node: Node) {
if (!("getAttribute" in _node)) {
return DEFAULT_ADMONITION_TYPE;
}
const node = _node as Element;
const classList = node.getAttribute("class")?.split(" ") ?? [];
for (const className of classList) {
if (className === "admonition") {
continue;
}
const mappedType = ADMONITION_TYPE_MAPPINGS[className];
if (mappedType) {
return mappedType;
}
}
return DEFAULT_ADMONITION_TYPE;
}
const admonitionFilter: Rule = {
filter(node, options) {
return node.nodeName === "ASIDE" && node.classList.contains("admonition");
},
replacement(content, node) {
// Parse the admonition type.
const admonitionType = parseAdmonitionType(node);
content = content.replace(/^\n+|\n+$/g, '');
content = content.replace(/^/gm, '> ');
content = `> [!${admonitionType}]\n${content}`;
return `\n\n${content}\n\n`;
}
};
return admonitionFilter;
}
/**
* Variation of the original ruleset: https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js.
*
* Detects if the URL is a Trilium reference link and returns it verbatim if that's the case.
*
* @returns
*/
function buildInlineLinkFilter(): Rule {
return {
filter (node, options) {
return (
options.linkStyle === 'inlined' &&
node.nodeName === 'A' &&
!!node.getAttribute('href')
);
},
replacement (content, _node) {
const node = _node as HTMLElement;
// Return reference links verbatim.
if (node.classList.contains("reference-link")) {
return node.outerHTML;
}
// Otherwise treat as normal.
// TODO: Call super() somehow instead of duplicating the implementation.
let href = node.getAttribute('href');
if (href) href = href.replace(/([()])/g, '\\$1');
let title = cleanAttribute(node.getAttribute('title'));
if (title) title = ` "${title.replace(/"/g, '\\"')}"`;
return `[${content}](${href}${title})`;
}
};
}
function buildFigureFilter(): Rule {
return {
filter(node, options) {
return node.nodeName === 'FIGURE'
&& node.classList.contains("image");
},
replacement(content, node) {
return (node as HTMLElement).outerHTML;
}
};
}
// Keep in line with https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js.
function buildListItemFilter(): Rule {
return {
filter: "li",
replacement(content, node, options) {
content = content
.trim()
.replace(/\n/gm, '\n '); // indent
let prefix = `${options.bulletListMarker} `;
const parent = node.parentNode as HTMLElement;
if (parent.nodeName === 'OL') {
const start = parent.getAttribute('start');
const index = Array.prototype.indexOf.call(parent.children, node);
prefix = `${start ? Number(start) + index : index + 1}. `;
} else if (parent.classList.contains("todo-list")) {
const isChecked = node.querySelector("input[type=checkbox]:checked");
prefix = (isChecked ? "- [x] " : "- [ ] ");
}
const result = prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '');
return result;
}
};
}
function buildMathFilter(): Rule {
const MATH_INLINE_PREFIX = "\\(";
const MATH_INLINE_SUFFIX = "\\)";
const MATH_DISPLAY_PREFIX = "\\[";
const MATH_DISPLAY_SUFFIX = "\\]";
return {
filter(node) {
return node.nodeName === "SPAN" && node.classList.contains("math-tex");
},
replacement(_, node) {
// We have to use the raw HTML text, otherwise the content is escaped too much.
const content = (node as HTMLElement).innerText;
// Inline math
if (content.startsWith(MATH_INLINE_PREFIX) && content.endsWith(MATH_INLINE_SUFFIX)) {
return `$${content.substring(MATH_INLINE_PREFIX.length, content.length - MATH_INLINE_SUFFIX.length)}$`;
}
// Display math
if (content.startsWith(MATH_DISPLAY_PREFIX) && content.endsWith(MATH_DISPLAY_SUFFIX)) {
return `$$${content.substring(MATH_DISPLAY_PREFIX.length, content.length - MATH_DISPLAY_SUFFIX.length)}$$`;
}
// Unknown.
return content;
}
};
}
// Taken from upstream since it's not exposed.
// https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js
function cleanAttribute(attribute: string | null | undefined) {
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '';
}
export default {
toMarkdown
};

View File

@@ -1,9 +1,9 @@
import { utils } from "@triliumnext/core";
import type { Response } from "express";
import becca from "../../becca/becca.js";
import type BBranch from "../../becca/entities/bbranch.js";
import type TaskContext from "../task_context.js";
import { getContentDisposition, stripTags } from "../utils/index.js";
function exportToOpml(taskContext: TaskContext<"export">, branch: BBranch, version: string, res: Response) {
if (!["1.0", "2.0"].includes(version)) {
@@ -58,7 +58,7 @@ function exportToOpml(taskContext: TaskContext<"export">, branch: BBranch, versi
const filename = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.title}.opml`;
res.setHeader("Content-Disposition", getContentDisposition(filename));
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
res.setHeader("Content-Type", "text/x-opml");
res.write(`<?xml version="1.0" encoding="UTF-8"?>
@@ -82,7 +82,7 @@ function exportToOpml(taskContext: TaskContext<"export">, branch: BBranch, versi
function prepareText(text: string) {
const newLines = text.replace(/(<p[^>]*>|<br\s*\/?>)/g, "\n").replace(/&nbsp;/g, " "); // nbsp isn't in XML standard (only HTML)
const stripped = stripTags(newLines);
const stripped = utils.stripTags(newLines);
const escaped = escapeXmlAttribute(stripped);

View File

@@ -8,10 +8,9 @@ import becca from "../../becca/becca.js";
import type BBranch from "../../becca/entities/bbranch.js";
import type BNote from "../../becca/entities/bnote.js";
import type TaskContext from "../task_context.js";
import { escapeHtml,getContentDisposition } from "../utils/index.js";
import { escapeHtml,getContentDisposition } from "../utils.js";
import mdService from "./markdown.js";
import { ExportFormat } from "../../meta.js";
import { encodeBase64 } from "../utils/binary.js";
import type { ExportFormat } from "./zip/abstract_provider.js";
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) {
const note = branch.getNote();
@@ -89,11 +88,11 @@ function inlineAttachments(content: string) {
}
const imageContent = note.getContent();
if (typeof imageContent === "string") {
if (!Buffer.isBuffer(imageContent)) {
return match;
}
const base64Content = encodeBase64(imageContent);
const base64Content = imageContent.toString("base64");
const srcValue = `data:${note.mime};base64,${base64Content}`;
return `src="${srcValue}"`;
@@ -106,11 +105,11 @@ function inlineAttachments(content: string) {
}
const attachmentContent = attachment.getContent();
if (typeof attachmentContent === "string") {
if (!Buffer.isBuffer(attachmentContent)) {
return match;
}
const base64Content = encodeBase64(attachmentContent);
const base64Content = attachmentContent.toString("base64");
const srcValue = `data:${attachment.mime};base64,${base64Content}`;
return `src="${srcValue}"`;
@@ -123,11 +122,11 @@ function inlineAttachments(content: string) {
}
const attachmentContent = attachment.getContent();
if (typeof attachmentContent === "string") {
if (!Buffer.isBuffer(attachmentContent)) {
return match;
}
const base64Content = encodeBase64(attachmentContent);
const base64Content = attachmentContent.toString("base64");
const hrefValue = `data:${attachment.mime};base64,${base64Content}`;
return `href="${hrefValue}" download="${escapeHtml(attachment.title)}"`;

View File

@@ -1,32 +1,43 @@
import { NoteType } from "@triliumnext/commons";
import { ValidationError } from "@triliumnext/core";
import archiver from "archiver";
import type { Response } from "express";
import fs from "fs";
import path from "path";
import sanitize from "sanitize-filename";
import packageInfo from "../../../package.json" with { type: "json" };
import becca from "../../becca/becca.js";
import BBranch from "../../becca/entities/bbranch.js";
import type BNote from "../../becca/entities/bnote.js";
import dateUtils from "../utils/date.js";
import { getLog } from "../log.js";
import dateUtils from "../date_utils.js";
import log from "../log.js";
import type AttachmentMeta from "../meta/attachment_meta.js";
import type AttributeMeta from "../meta/attribute_meta.js";
import type NoteMeta from "../meta/note_meta.js";
import type { NoteMetaFile } from "../meta/note_meta.js";
import protectedSessionService from "../protected_session.js";
import TaskContext from "../task_context.js";
import { getZipProvider } from "../zip_provider.js";
import { getContentDisposition } from "../utils/index"
import { AdvancedExportOptions, ZipExportProviderData } from "./zip/abstract_provider.js";
import { getZipExportProviderFactory } from "./zip_export_provider_factory.js";
import { AttachmentMeta, AttributeMeta, ExportFormat, NoteMeta, NoteMetaFile } from "../../meta";
import { ValidationError } from "../../errors";
import { extname } from "../utils/path";
import { getContentDisposition, waitForStreamToFinish } from "../utils.js";
import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js";
import HtmlExportProvider from "./zip/html.js";
import MarkdownExportProvider from "./zip/markdown.js";
import ShareThemeExportProvider from "./zip/share_theme.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Record<string, any>, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
const archive = getZipProvider().createZipArchive();
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 = await buildProvider();
const log = getLog();
const provider = buildProvider();
const noteIdToMeta: Record<string, NoteMeta> = {};
async function buildProvider() {
function buildProvider() {
const providerData: ZipExportProviderData = {
getNoteTargetUrl,
archive,
@@ -35,7 +46,16 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
zipExportOptions
};
return getZipExportProviderFactory()(format, providerData);
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) {
@@ -76,7 +96,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
fileName = fileName.slice(0, 30 - croppedExt.length) + croppedExt;
}
const existingExtension = extname(fileName).toLowerCase();
const existingExtension = path.extname(fileName).toLowerCase();
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
@@ -323,7 +343,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
content = prepareContent(noteMeta.title, content, noteMeta, undefined);
archive.append(typeof content === "string" ? content : new Uint8Array(content), {
archive.append(typeof content === "string" ? content : Buffer.from(content), {
name: filePathPrefix + noteMeta.dataFileName
});
@@ -341,7 +361,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
if (noteMeta.dataFileName) {
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note);
archive.append(content as string | Uint8Array, {
archive.append(content as string | Buffer, {
name: filePathPrefix + noteMeta.dataFileName,
date: dateUtils.parseDateTime(note.utcDateModified)
});
@@ -357,7 +377,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
const attachment = note.getAttachmentById(attachmentMeta.attachmentId);
const content = attachment.getContent();
archive.append(typeof content === "string" ? content : new Uint8Array(content), {
archive.append(typeof content === "string" ? content : Buffer.from(content), {
name: filePathPrefix + attachmentMeta.dataFileName,
date: dateUtils.parseDateTime(note.utcDateModified)
});
@@ -451,7 +471,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
const { destination, waitForFinish } = getZipProvider().createFileStream(zipFilePath);
const fileOutputStream = fs.createWriteStream(zipFilePath);
const taskContext = new TaskContext("no-progress-reporting", "export", null);
const note = becca.getNote(noteId);
@@ -460,10 +480,10 @@ async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath
throw new ValidationError(`Note ${noteId} not found.`);
}
await exportToZip(taskContext, note.getParentBranches()[0], format, destination as Record<string, any>, false, zipExportOptions);
await waitForFinish();
await exportToZip(taskContext, note.getParentBranches()[0], format, fileOutputStream, false, zipExportOptions);
await waitForStreamToFinish(fileOutputStream);
getLog().info(`Exported '${noteId}' with format '${format}' to '${zipFilePath}'`);
log.info(`Exported '${noteId}' with format '${format}' to '${zipFilePath}'`);
}
export default {

View File

@@ -1,10 +1,13 @@
import { NoteType } from "@triliumnext/commons";
import { ExportFormat } from "@triliumnext/core";
import { Archiver } from "archiver";
import mimeTypes from "mime-types";
import type BBranch from "../../../becca/entities/bbranch.js";
import type BNote from "../../../becca/entities/bnote.js";
import { ExportFormat, NoteMeta, NoteMetaFile } from "../../../meta.js";
import type { ZipArchive } from "../../zip_provider.js";
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
export type { ExportFormat, NoteMeta } from "@triliumnext/core";
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
@@ -29,7 +32,7 @@ export interface AdvancedExportOptions {
export interface ZipExportProviderData {
branch: BBranch;
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
archive: ZipArchive;
archive: Archiver;
zipExportOptions: AdvancedExportOptions | undefined;
rewriteFn: RewriteLinksFn;
}
@@ -37,7 +40,7 @@ export interface ZipExportProviderData {
export abstract class ZipExportProvider {
branch: BBranch;
getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null;
archive: ZipArchive;
archive: Archiver;
zipExportOptions?: AdvancedExportOptions;
rewriteFn: RewriteLinksFn;

View File

@@ -1,31 +0,0 @@
import { type ExportFormat, ZipExportProvider, type ZipExportProviderData } from "@triliumnext/core";
import fs from "fs";
import path from "path";
import { getResourceDir, isDev } from "../../utils.js";
function readContentCss(): string {
const cssFile = isDev
? path.join(__dirname, "../../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css")
: path.join(getResourceDir(), "ckeditor5-content.css");
return fs.readFileSync(cssFile, "utf-8");
}
export async function serverZipExportProviderFactory(format: ExportFormat, data: ZipExportProviderData): Promise<ZipExportProvider> {
switch (format) {
case "html": {
const { default: HtmlExportProvider } = await import("@triliumnext/core/src/services/export/zip/html.js");
return new HtmlExportProvider(data, { contentCss: readContentCss() });
}
case "markdown": {
const { default: MarkdownExportProvider } = await import("@triliumnext/core/src/services/export/zip/markdown.js");
return new MarkdownExportProvider(data);
}
case "share": {
const { default: ShareThemeExportProvider } = await import("./share_theme.js");
return new ShareThemeExportProvider(data);
}
default:
throw new Error(`Unsupported export format: '${format}'`);
}
}

View File

@@ -1,24 +1,16 @@
import fs from "fs";
import html from "html";
import path from "path";
import { escapeHtml } from "../../utils/index";
import { ZipExportProvider, ZipExportProviderData } from "./abstract_provider.js";
import { NoteMeta } from "../../../meta";
export interface HtmlExportProviderOptions {
contentCss?: string;
}
import type NoteMeta from "../../meta/note_meta.js";
import { escapeHtml, getResourceDir, isDev } from "../../utils";
import { ZipExportProvider } from "./abstract_provider.js";
export default class HtmlExportProvider extends ZipExportProvider {
private navigationMeta: NoteMeta | null = null;
private indexMeta: NoteMeta | null = null;
private cssMeta: NoteMeta | null = null;
private options: HtmlExportProviderOptions;
constructor(data: ZipExportProviderData, options?: HtmlExportProviderOptions) {
super(data);
this.options = options ?? {};
}
prepareMeta(metaFile) {
if (this.zipExportOptions?.skipExtraFiles) return;
@@ -178,9 +170,11 @@ export default class HtmlExportProvider extends ZipExportProvider {
return;
}
if (this.options.contentCss) {
this.archive.append(this.options.contentCss, { name: cssMeta.dataFileName });
}
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

@@ -1,4 +1,4 @@
import { NoteMeta } from "../../../meta.js";
import NoteMeta from "../../meta/note_meta";
import mdService from "../markdown.js";
import { ZipExportProvider } from "./abstract_provider.js";

View File

@@ -1,4 +1,3 @@
import { ExportFormat, icon_packs as iconPackService, ZipExportProvider } from "@triliumnext/core";
import ejs from "ejs";
import fs, { readdirSync, readFileSync } from "fs";
import { convert as convertToText } from "html-to-text";
@@ -10,10 +9,12 @@ import type BBranch from "../../../becca/entities/bbranch.js";
import type BNote from "../../../becca/entities/bnote.js";
import { getClientDir, getShareThemeAssetDir } from "../../../routes/assets";
import { getDefaultTemplatePath, readTemplate, renderNoteForExport } from "../../../share/content_renderer";
import { icon_packs as iconPackService } from "@triliumnext/core";
import log from "../../log";
import NoteMeta, { NoteMetaFile } from "../../meta/note_meta";
import { RESOURCE_DIR } from "../../resource_dir";
import { getResourceDir, isDev } from "../../utils";
import { ExportFormat, ZipExportProvider } from "./abstract_provider.js";
const shareThemeAssetDir = getShareThemeAssetDir();

View File

@@ -13,7 +13,6 @@ export const isWindows11 = isWindows && osVersion[0] === 10 && osVersion[2] >= 2
export const isElectron = !!process.versions["electron"];
/** @deprecated Use `isDev()` from `@triliumnext/core` instead. */
export const isDev = !!(process.env.TRILIUM_ENV && process.env.TRILIUM_ENV === "dev");
/** @deprecated */

View File

@@ -1,5 +1,6 @@
import { renderSpreadsheetToHtml } from "@triliumnext/commons";
import { icon_packs as iconPackService, sanitize, utils } from "@triliumnext/core";
import { sanitize } from "@triliumnext/core";
import { icon_packs as iconPackService } from "@triliumnext/core";
import { highlightAuto } from "@triliumnext/highlightjs";
import ejs from "ejs";
import escapeHtml from "escape-html";
@@ -15,7 +16,7 @@ import BNote from "../becca/entities/bnote.js";
import assetPath, { assetUrlFragment } from "../services/asset_path.js";
import log from "../services/log.js";
import options from "../services/options.js";
import { getResourceDir, isDev } from "../services/utils.js";
import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js";
import SAttachment from "./shaca/entities/sattachment.js";
import SBranch from "./shaca/entities/sbranch.js";
import type SNote from "./shaca/entities/snote.js";
@@ -223,7 +224,7 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs)
return ejs.render(content, opts, { includer });
}
} catch (e: unknown) {
const [errMessage, errStack] = utils.safeExtractMessageAndStackFromError(e);
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`);
}
}

View File

@@ -1,30 +1,6 @@
import type { FileStream, ZipArchive, ZipEntry, ZipProvider } from "@triliumnext/core/src/services/zip_provider.js";
import archiver, { type Archiver } from "archiver";
import fs from "fs";
import type { ZipEntry, ZipProvider } from "@triliumnext/core/src/services/import/zip_provider.js";
import type { Stream } from "stream";
import * as yauzl from "yauzl";
class NodejsZipArchive implements ZipArchive {
readonly #archive: Archiver;
constructor() {
this.#archive = archiver("zip", {
zlib: { level: 9 }
});
}
append(content: string | Uint8Array, options: { name: string; date?: Date }) {
this.#archive.append(typeof content === "string" ? content : Buffer.from(content), options);
}
pipe(destination: unknown) {
this.#archive.pipe(destination as NodeJS.WritableStream);
}
finalize(): Promise<void> {
return this.#archive.finalize();
}
}
import yauzl from "yauzl";
function streamToBuffer(stream: Stream): Promise<Buffer> {
const chunks: Uint8Array[] = [];
@@ -36,21 +12,6 @@ function streamToBuffer(stream: Stream): Promise<Buffer> {
}
export default class NodejsZipProvider implements ZipProvider {
createZipArchive(): ZipArchive {
return new NodejsZipArchive();
}
createFileStream(filePath: string): FileStream {
const stream = fs.createWriteStream(filePath);
return {
destination: stream,
waitForFinish: () => new Promise((resolve, reject) => {
stream.on("finish", resolve);
stream.on("error", reject);
})
};
}
readZipFile(
buffer: Uint8Array,
processEntry: (entry: ZipEntry, readContent: () => Promise<Uint8Array>) => Promise<void>

View File

@@ -9,7 +9,6 @@
"dependencies": {
"@braintree/sanitize-url": "7.1.1",
"@triliumnext/commons": "workspace:*",
"@triliumnext/turndown-plugin-gfm": "workspace:*",
"async-mutex": "0.5.0",
"chardet": "2.1.1",
"escape-html": "1.0.3",

View File

@@ -9,8 +9,7 @@ import { initTranslations, TranslationProvider } from "./services/i18n";
import { initSchema, initDemoArchive } from "./services/sql_init";
import appInfo from "./services/app_info";
import { type PlatformProvider, initPlatform } from "./services/platform";
import { type ZipProvider, initZipProvider } from "./services/zip_provider";
import { type ZipExportProviderFactory, initZipExportProviderFactory } from "./services/export/zip_export_provider_factory";
import { type ZipProvider, initZipProvider } from "./services/import/zip_provider";
import markdown from "./services/import/markdown";
export { getLog } from "./services/log";
@@ -102,20 +101,15 @@ export type { RequestProvider, ExecOpts, CookieJar } from "./services/request";
export type * from "./meta";
export * as routeHelpers from "./routes/helpers";
export { getZipProvider, type ZipArchive, type ZipProvider } from "./services/zip_provider";
export { getZipProvider, type ZipProvider } from "./services/import/zip_provider";
export { default as zipImportService } from "./services/import/zip";
export { default as zipExportService } from "./services/export/zip";
export { type AdvancedExportOptions, type ZipExportProviderData } from "./services/export/zip/abstract_provider";
export { ZipExportProvider } from "./services/export/zip/abstract_provider";
export { type ZipExportProviderFactory } from "./services/export/zip_export_provider_factory";
export { type ExportFormat } from "./meta";
export * as becca_easy_mocking from "./test/becca_easy_mocking";
export * as becca_mocking from "./test/becca_mocking";
export { default as markdownImportService } from "./services/import/markdown";
export async function initializeCore({ dbConfig, executionContext, crypto, zip, zipExportProviderFactory, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive }: {
export async function initializeCore({ dbConfig, executionContext, crypto, zip, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive }: {
dbConfig: SqlServiceParams,
executionContext: ExecutionContext,
crypto: CryptoProvider,
@@ -123,7 +117,6 @@ export async function initializeCore({ dbConfig, executionContext, crypto, zip,
translations: TranslationProvider,
platform: PlatformProvider,
schema: string,
zipExportProviderFactory: ZipExportProviderFactory,
messaging?: MessagingProvider,
request?: RequestProvider,
getDemoArchive?: () => Promise<Uint8Array | null>,
@@ -137,7 +130,6 @@ export async function initializeCore({ dbConfig, executionContext, crypto, zip,
await initTranslations(translations);
initCrypto(crypto);
initZipProvider(zip);
initZipExportProviderFactory(zipExportProviderFactory);
initContext(executionContext);
initSql(new SqlService(dbConfig, getLog()));
initSchema(schema);

View File

@@ -1,31 +1,5 @@
import becca from "../../becca/becca";
import { RenderMarkdownResponse, ToMarkdownResponse } from "@triliumnext/commons";
import type { Request } from "express";
import markdown from "../../services/export/markdown.js";
import { markdownImportService } from "../..";
function renderMarkdown(req: Request) {
const { markdownContent } = req.body;
if (!markdownContent || typeof markdownContent !== 'string') {
throw new Error('markdownContent parameter is required and must be a string');
}
return {
htmlContent: markdownImportService.renderToHtml(markdownContent, "")
} satisfies RenderMarkdownResponse;
}
function toMarkdown(req: Request) {
const { htmlContent } = req.body;
if (!htmlContent || typeof htmlContent !== 'string') {
throw new Error('htmlContent parameter is required and must be a string');
}
return {
markdownContent: markdown.toMarkdown(htmlContent)
} satisfies ToMarkdownResponse;
}
function getIconUsage() {
const iconClassToCountMap: Record<string, number> = {};
@@ -51,7 +25,5 @@ function getIconUsage() {
}
export default {
getIconUsage,
renderMarkdown,
toMarkdown
getIconUsage
}

View File

@@ -26,7 +26,6 @@ import imageRoute from "./api/image";
import setupApiRoute from "./api/setup";
import filesRoute from "./api/files";
import importRoute from "./api/import";
import exportRoute from "./api/export";
// TODO: Deduplicate with routes.ts
const GET = "get",
@@ -117,7 +116,6 @@ export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRout
apiRoute(PUT, "/api/branches/:branchId/set-prefix", branchesApiRoute.setPrefix);
apiRoute(PUT, "/api/branches/set-prefix-batch", branchesApiRoute.setPrefixBatch);
// :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename
route(GET, "/api/revisions/:revisionId/image/:filename", [checkApiAuthOrElectron], imageRoute.returnImageFromRevision);
route(GET, "/api/attachments/:attachmentId/image/:filename", [checkApiAuthOrElectron], imageRoute.returnAttachedImage);
route(GET, "/api/images/:noteId/:filename", [checkApiAuthOrElectron], imageRoute.returnImageFromNote);
@@ -141,11 +139,8 @@ export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRout
route(PST, "/api/sync/queue-sector/:entityName/:sector", [checkApiAuth], syncApiRoute.queueSector, apiResultHandler);
route(GET, "/api/sync/stats", [], syncApiRoute.getStats, apiResultHandler);
//#region Import/export
asyncRoute(PST, "/api/notes/:parentNoteId/notes-import", [checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], importRoute.importNotesToBranch, apiResultHandler);
route(PST, "/api/notes/:parentNoteId/attachments-import", [checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], importRoute.importAttachmentsToNote, apiResultHandler);
asyncRoute(GET, "/api/branches/:branchId/export/:type/:format/:version/:taskId", [checkApiAuthOrElectron], exportRoute.exportBranch);
//#endregion
apiRoute(GET, "/api/quick-search/:searchString", searchRoute.quickSearch);
apiRoute(GET, "/api/search-note/:noteId", searchRoute.searchFromNote);
@@ -198,11 +193,7 @@ export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRout
apiRoute(PST, "/api/bulk-action/affected-notes", bulkActionRoute.getAffectedNoteCount);
apiRoute(GET, "/api/app-info", appInfoRoute.getAppInfo);
apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage);
apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown);
apiRoute(PST, "/api/other/to-markdown", otherRoute.toMarkdown);
asyncApiRoute(GET, "/api/similar-notes/:noteId", similarNotesRoute.getSimilarNotes);
apiRoute(PST, "/api/relation-map", relationMapApiRoute.getRelationMap);
apiRoute(GET, "/api/recent-changes/:ancestorNoteId", recentChangesApiRoute.getRecentChanges);

View File

@@ -1,8 +1,3 @@
import { gfm } from "@triliumnext/turndown-plugin-gfm";
import Turnish, { type Rule } from "turnish";
let instance: Turnish | null = null;
// TODO: Move this to a dedicated file someday.
export const ADMONITION_TYPE_MAPPINGS: Record<string, string> = {
note: "NOTE",
@@ -13,272 +8,3 @@ export const ADMONITION_TYPE_MAPPINGS: Record<string, string> = {
};
export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPE_MAPPINGS.note;
const fencedCodeBlockFilter: Rule = {
filter (node, options) {
return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild !== null && node.firstChild.nodeName === "CODE";
},
replacement (content, node, options) {
if (!node.firstChild || !("getAttribute" in node.firstChild) || typeof node.firstChild.getAttribute !== "function") {
return content;
}
const className = node.firstChild.getAttribute("class") || "";
const language = rewriteLanguageTag((className.match(/language-(\S+)/) || [null, ""])[1]);
return `\n\n${options.fence}${language}\n${node.firstChild.textContent}\n${options.fence}\n\n`;
}
};
function toMarkdown(content: string) {
if (instance === null) {
instance = new Turnish({
headingStyle: "atx",
bulletListMarker: "*",
emDelimiter: "_",
codeBlockStyle: "fenced",
blankReplacement(_content, node) {
if (node.nodeName === "SECTION" && node.classList.contains("include-note")) {
return node.outerHTML;
}
// Original implementation as per https://github.com/mixmark-io/turndown/blob/master/src/turndown.js.
return ("isBlock" in node && node.isBlock) ? '\n\n' : '';
},
});
// Filter is heavily based on: https://github.com/mixmark-io/turndown/issues/274#issuecomment-458730974
instance.addRule("fencedCodeBlock", fencedCodeBlockFilter);
instance.addRule("img", buildImageFilter());
instance.addRule("admonition", buildAdmonitionFilter());
instance.addRule("inlineLink", buildInlineLinkFilter());
instance.addRule("figure", buildFigureFilter());
instance.addRule("math", buildMathFilter());
instance.addRule("li", buildListItemFilter());
instance.use(gfm);
instance.keep([ "kbd", "sup", "sub" ]);
}
return instance.render(content);
}
function rewriteLanguageTag(source: string) {
if (!source) {
return source;
}
switch (source) {
case "text-x-trilium-auto":
return "";
case "application-javascript-env-frontend":
case "application-javascript-env-backend":
return "javascript";
case "text-x-nginx-conf":
return "nginx";
default:
return source.split("-").at(-1);
}
}
// TODO: Remove once upstream delivers a fix for https://github.com/mixmark-io/turndown/issues/467.
function buildImageFilter() {
const ESCAPE_PATTERNS = {
before: /([\\*`[\]_]|(?:^[-+>])|(?:^~~~)|(?:^#{1-6}))/g,
after: /((?:^\d+(?=\.)))/
};
const escapePattern = new RegExp(`(?:${ESCAPE_PATTERNS.before.source}|${ESCAPE_PATTERNS.after.source})`, 'g');
function escapeMarkdown (content: string) {
return content.replace(escapePattern, (match, before, after) => {
return before ? `\\${before}` : `${after}\\`;
});
}
function escapeLinkDestination(destination: string) {
return destination
.replace(/([()])/g, '\\$1')
.replace(/ /g, "%20");
}
function escapeLinkTitle (title: string) {
return title.replace(/"/g, '\\"');
}
const imageFilter: Rule = {
filter: "img",
replacement(content, _node) {
const node = _node as HTMLElement;
// Preserve image verbatim if it has a width or height attribute.
if (node.hasAttribute("width") || node.hasAttribute("height")) {
return node.outerHTML;
}
// TODO: Deduplicate with upstream.
const untypedNode = (node as any);
const alt = escapeMarkdown(cleanAttribute(untypedNode.getAttribute('alt')));
const src = escapeLinkDestination(untypedNode.getAttribute('src') || '');
const title = cleanAttribute(untypedNode.getAttribute('title'));
const titlePart = title ? ` "${escapeLinkTitle(title)}"` : '';
return src ? `![${alt}](${src}${titlePart})` : '';
}
};
return imageFilter;
}
function buildAdmonitionFilter() {
function parseAdmonitionType(_node: Node) {
if (!("getAttribute" in _node)) {
return DEFAULT_ADMONITION_TYPE;
}
const node = _node as Element;
const classList = node.getAttribute("class")?.split(" ") ?? [];
for (const className of classList) {
if (className === "admonition") {
continue;
}
const mappedType = ADMONITION_TYPE_MAPPINGS[className];
if (mappedType) {
return mappedType;
}
}
return DEFAULT_ADMONITION_TYPE;
}
const admonitionFilter: Rule = {
filter(node, options) {
return node.nodeName === "ASIDE" && node.classList.contains("admonition");
},
replacement(content, node) {
// Parse the admonition type.
const admonitionType = parseAdmonitionType(node);
content = content.replace(/^\n+|\n+$/g, '');
content = content.replace(/^/gm, '> ');
content = `> [!${admonitionType}]\n${content}`;
return `\n\n${content}\n\n`;
}
};
return admonitionFilter;
}
/**
* Variation of the original ruleset: https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js.
*
* Detects if the URL is a Trilium reference link and returns it verbatim if that's the case.
*
* @returns
*/
function buildInlineLinkFilter(): Rule {
return {
filter (node, options) {
return (
options.linkStyle === 'inlined' &&
node.nodeName === 'A' &&
!!node.getAttribute('href')
);
},
replacement (content, _node) {
const node = _node as HTMLElement;
// Return reference links verbatim.
if (node.classList.contains("reference-link")) {
return node.outerHTML;
}
// Otherwise treat as normal.
// TODO: Call super() somehow instead of duplicating the implementation.
let href = node.getAttribute('href');
if (href) href = href.replace(/([()])/g, '\\$1');
let title = cleanAttribute(node.getAttribute('title'));
if (title) title = ` "${title.replace(/"/g, '\\"')}"`;
return `[${content}](${href}${title})`;
}
};
}
function buildFigureFilter(): Rule {
return {
filter(node, options) {
return node.nodeName === 'FIGURE'
&& node.classList.contains("image");
},
replacement(content, node) {
return (node as HTMLElement).outerHTML;
}
};
}
// Keep in line with https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js.
function buildListItemFilter(): Rule {
return {
filter: "li",
replacement(content, node, options) {
content = content
.trim()
.replace(/\n/gm, '\n '); // indent
let prefix = `${options.bulletListMarker} `;
const parent = node.parentNode as HTMLElement;
if (parent.nodeName === 'OL') {
const start = parent.getAttribute('start');
const index = Array.prototype.indexOf.call(parent.children, node);
prefix = `${start ? Number(start) + index : index + 1}. `;
} else if (parent.classList.contains("todo-list")) {
const isChecked = node.querySelector("input[type=checkbox]:checked");
prefix = (isChecked ? "- [x] " : "- [ ] ");
}
const result = prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '');
return result;
}
};
}
function buildMathFilter(): Rule {
const MATH_INLINE_PREFIX = "\\(";
const MATH_INLINE_SUFFIX = "\\)";
const MATH_DISPLAY_PREFIX = "\\[";
const MATH_DISPLAY_SUFFIX = "\\]";
return {
filter(node) {
return node.nodeName === "SPAN" && node.classList.contains("math-tex");
},
replacement(_, node) {
// We have to use the raw HTML text, otherwise the content is escaped too much.
const content = (node as HTMLElement).innerText;
// Inline math
if (content.startsWith(MATH_INLINE_PREFIX) && content.endsWith(MATH_INLINE_SUFFIX)) {
return `$${content.substring(MATH_INLINE_PREFIX.length, content.length - MATH_INLINE_SUFFIX.length)}$`;
}
// Display math
if (content.startsWith(MATH_DISPLAY_PREFIX) && content.endsWith(MATH_DISPLAY_SUFFIX)) {
return `$$${content.substring(MATH_DISPLAY_PREFIX.length, content.length - MATH_DISPLAY_SUFFIX.length)}$$`;
}
// Unknown.
return content;
}
};
}
// Taken from upstream since it's not exposed.
// https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js
function cleanAttribute(attribute: string | null | undefined) {
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '';
}
export default {
toMarkdown
};

View File

@@ -1,15 +0,0 @@
import type { ExportFormat } from "../../meta.js";
import type { ZipExportProvider, ZipExportProviderData } from "./zip/abstract_provider.js";
export type ZipExportProviderFactory = (format: ExportFormat, data: ZipExportProviderData) => Promise<ZipExportProvider>;
let factory: ZipExportProviderFactory | null = null;
export function initZipExportProviderFactory(f: ZipExportProviderFactory) {
factory = f;
}
export function getZipExportProviderFactory(): ZipExportProviderFactory {
if (!factory) throw new Error("ZipExportProviderFactory not initialized.");
return factory;
}

View File

@@ -1,5 +1,6 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import becca from "../../becca/becca.js";
@@ -12,7 +13,7 @@ import { getContext } from "../context.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
async function testImport(fileName: string, mimetype: string) {
const buffer = fs.readFileSync(`${scriptDir}/samples/${fileName}`);
const buffer = fs.readFileSync(path.join(scriptDir, "samples", fileName));
const taskContext = TaskContext.getInstance("import-mdx", "importNotes", {
textImportedAsText: true,
codeImportedAsCode: true

View File

@@ -1,5 +1,6 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import zip, { removeTriliumTags } from "./zip.js";
@@ -12,7 +13,7 @@ import { getContext } from "../context.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
async function testImport(fileName: string) {
const mdxSample = fs.readFileSync(`${scriptDir}/samples/${fileName}`);
const mdxSample = fs.readFileSync(path.join(scriptDir, "samples", fileName));
const taskContext = TaskContext.getInstance("import-mdx", "importNotes", {
textImportedAsText: true
});

View File

@@ -1,6 +1,6 @@
import { ALLOWED_NOTE_TYPES, type NoteType } from "@triliumnext/commons";
import { basename, dirname } from "../utils/path.js";
import { getZipProvider } from "../zip_provider.js";
import { getZipProvider } from "./zip_provider.js";
import becca from "../../becca/becca.js";
import BAttachment from "../../becca/entities/battachment.js";

View File

@@ -2,24 +2,6 @@ export interface ZipEntry {
fileName: string;
}
export interface ZipArchiveEntryOptions {
name: string;
date?: Date;
}
export interface ZipArchive {
append(content: string | Uint8Array, options: ZipArchiveEntryOptions): void;
pipe(destination: unknown): void;
finalize(): Promise<void>;
}
export interface FileStream {
/** An opaque writable destination that can be passed to {@link ZipArchive.pipe}. */
destination: unknown;
/** Resolves when the stream has finished writing (or rejects on error). */
waitForFinish(): Promise<void>;
}
export interface ZipProvider {
/**
* Iterates over every entry in a ZIP buffer, calling `processEntry` for each one.
@@ -29,11 +11,6 @@ export interface ZipProvider {
buffer: Uint8Array,
processEntry: (entry: ZipEntry, readContent: () => Promise<Uint8Array>) => Promise<void>
): Promise<void>;
createZipArchive(): ZipArchive;
/** Creates a writable file stream for the given path. */
createFileStream(filePath: string): FileStream;
}
let zipProvider: ZipProvider | null = null;

View File

@@ -2,6 +2,7 @@ import { type AttachmentRow, type AttributeRow, type BranchRow, dayjs, type Note
import fs from "fs";
import html2plaintext from "html2plaintext";
import { t } from "i18next";
import path from "path";
import url from "url";
import becca from "../becca/becca.js";
@@ -27,7 +28,6 @@ import { getSql } from "./sql/index.js";
import { sanitizeHtml } from "./sanitizer.js";
import { ValidationError } from "../errors.js";
import * as cls from "./context.js";
import { basename } from "./utils/path.js";
interface FoundLink {
name: "imageLink" | "internalLink" | "includeNoteLink" | "relationMapLink";
@@ -552,7 +552,7 @@ async function downloadImage(noteId: string, imageUrl: string) {
}
const parsedUrl = url.parse(unescapedUrl);
const title = basename(parsedUrl.pathname || "");
const title = path.basename(parsedUrl.pathname || "");
const attachment = imageService.saveImageToAttachment(noteId, imageBuffer, title, true, true);

View File

@@ -8,7 +8,6 @@ import unescape from "unescape";
import { basename, extname } from "./path";
import { NoteMeta } from "../../meta";
export function isDev() { return getPlatform().getEnv("TRILIUM_ENV") === "dev"; }
export function isElectron() { return getPlatform().isElectron; }
export function isMac() { return getPlatform().isMac; }
export function isWindows() { return getPlatform().isWindows; }

20
pnpm-lock.yaml generated
View File

@@ -803,6 +803,9 @@ importers:
'@triliumnext/highlightjs':
specifier: workspace:*
version: link:../../packages/highlightjs
'@triliumnext/turndown-plugin-gfm':
specifier: workspace:*
version: link:../../packages/turndown-plugin-gfm
'@types/archiver':
specifier: 7.0.0
version: 7.0.0
@@ -1701,9 +1704,6 @@ importers:
'@triliumnext/commons':
specifier: workspace:*
version: link:../commons
'@triliumnext/turndown-plugin-gfm':
specifier: workspace:*
version: link:../turndown-plugin-gfm
async-mutex:
specifier: 0.5.0
version: 0.5.0
@@ -17587,6 +17587,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-html-embed@47.6.1':
dependencies:
@@ -17596,6 +17598,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-html-support@47.6.1':
dependencies:
@@ -17611,6 +17615,8 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-icons@47.6.1': {}
@@ -17628,6 +17634,8 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-import-word@47.6.1':
dependencies:
@@ -17651,6 +17659,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-inspector@5.0.0': {}
@@ -17661,6 +17671,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-line-height@47.6.1':
dependencies:
@@ -17685,6 +17697,8 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-list-multi-level@47.6.1':
dependencies: