chore(nx/server): move source code

This commit is contained in:
Elian Doran
2025-04-22 17:16:41 +03:00
parent 9c0d42252e
commit b2af043110
889 changed files with 10 additions and 30 deletions

View File

@@ -0,0 +1,345 @@
import { describe, it, expect } from "vitest";
import markdownExportService from "./markdown.js";
import { trimIndentation } from "@triliumnext/commons";
describe("Markdown export", () => {
it("exports correct language tag for known languages", () => {
const conversionTable = {
"language-text-x-nginx-conf": "nginx",
"language-text-x-diff": "diff",
"language-application-javascript-env-frontend": "javascript",
"language-application-javascript-env-backend": "javascript",
"language-text-x-asm-mips": "mips"
};
for (const [ input, output ] of Object.entries(conversionTable)) {
const html = trimIndentation`\
<p>A diff:</p>
<pre><code class="${input}">Hello
-world
+worldy
</code></pre>`;
const expected = trimIndentation`\
A diff:
\`\`\`${output}
Hello
-world
+worldy
\`\`\``;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
}
});
it("removes auto tag for code blocks", () => {
const html = trimIndentation`\
<pre><code class="language-text-x-trilium-auto">Hello
-world
+worldy
</code></pre>`;
const expected = trimIndentation`\
\`\`\`
Hello
-world
+worldy
\`\`\``;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("supports code block with no language tag", () => {
const html = trimIndentation`\
<pre><code>Hello</code></pre>`;
const expected = trimIndentation`\
\`\`\`
Hello
\`\`\``;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("exports strikethrough text correctly", () => {
const html = "<s>hello</s>Hello <s>world</s>";
const expected = "~~hello~~Hello ~~world~~";
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("exports headings properly", () => {
const html = trimIndentation`\
<h1>Heading 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
<h4>Heading 4</h4>
<h5>Heading 5</h5>
<h6>Heading 6</h6>
`;
const expected = trimIndentation`\
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("rewrites image URL with spaces", () => {
const html = `<img src="Hello world .png"/>`;
const expected = `![](Hello%20world%20%20.png)`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("supports keyboard shortcuts", () => {
const html = "<kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>Delete</kbd>";
expect(markdownExportService.toMarkdown(html)).toBe(html);
});
it("exports admonitions properly", () => {
const html = trimIndentation`\
<p>
Before
</p>
<aside class="admonition note">
<p>
This is a note.
</p>
</aside>
<aside class="admonition tip">
<p>
This is a tip.
</p>
</aside>
<aside class="admonition important">
<p>
This is a very important information.
</p>
<figure class="table">
<table>
<tbody>
<tr>
<td>
1
</td>
<td>
2
</td>
</tr>
<tr>
<td>
3
</td>
<td>
4
</td>
</tr>
</tbody>
</table>
</figure>
</aside>
<aside class="admonition caution">
<p>
This is a caution.
</p>
</aside>
<aside class="admonition warning">
<h2>
Title goes here
</h2>
<p>
This is a warning.
</p>
</aside>
<p>
After
</p>
`;
const space = " "; // editor config trimming space.
const expected = trimIndentation`\
Before
> [!NOTE]
> This is a note.
> [!TIP]
> This is a tip.
> [!IMPORTANT]
> This is a very important information.
>${space}
> <figure class="table"><table><tbody><tr><td>1</td><td>2</td></tr><tr><td>3</td><td>4</td></tr></tbody></table></figure>
> [!CAUTION]
> This is a caution.
> [!WARNING]
> ## Title goes here
>${space}
> This is a warning.
After`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("exports code in tables properly", () => {
const html = trimIndentation`\
<table>
<tr>
<td>
Row 1
</td>
<td>
<p>Allows displaying the value of one or more attributes in the calendar
like this:&nbsp;</p>
<p>
<img src="13_Calendar View_image.png" alt="">
</p>
<pre><code class="language-text-x-trilium-auto">#weight="70"
#Mood="Good"
#calendar:displayedAttributes="weight,Mood"</code></pre>
<p>It can also be used with relations, case in which it will display the
title of the target note:</p><pre><code class="language-text-x-trilium-auto">~assignee=@My assignee
#calendar:displayedAttributes="assignee"</code></pre>
</td>
</tr>
</table>
`;
const expected = trimIndentation`\
<table><tbody><tr><td>Row 1</td><td><p>Allows displaying the value of one or more attributes in the calendar like this:&nbsp;</p><p><img src="13_Calendar View_image.png" alt=""></p><pre><code class="language-text-x-trilium-auto">#weight="70"
#Mood="Good"
#calendar:displayedAttributes="weight,Mood"</code></pre><p>It can also be used with relations, case in which it will display the title of the target note:</p><pre><code class="language-text-x-trilium-auto">~assignee=@My assignee
#calendar:displayedAttributes="assignee"</code></pre></td></tr></tbody></table>`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("converts &nbsp; to character", () => {
const html = /*html*/`<p>Hello&nbsp;world.</p>`;
const expected = `Hello\u00a0world.`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("preserves non-breaking space character", () => {
const html = /*html*/`<p>Hello\u00adworld.</p>`;
const expected = `Hello\u00adworld.`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("exports normal links verbatim", () => {
const html = /*html*/`<p><a href="https://www.google.com">Google</a></p>`;
const expected = `[Google](https://www.google.com)`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("exports reference links verbatim", () => {
const html = /*html*/`<p><a class="reference-link" href="../../Canvas.html">Canvas</a></p>`;
const expected = `<a class="reference-link" href="../../Canvas.html">Canvas</a>`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("converts image if it has no custom properties", () => {
const html = /*html*/`<p><img src="Include Note_image.png"></p>`;
const expected = `![](Include%20Note_image.png)`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("preserves image verbatim if it has a width or height attribute", () => {
const scenarios = [
/*html*/`<img src="Include Note_image.png" width="16" height="16">`,
/*html*/`<img src="Include Note_image.png" width="16">`,
/*html*/`<img src="Include Note_image.png" height="16">`,
/*html*/`<img class="image_resized" style="aspect-ratio:853/315;width:50%;" src="6_File_image.png" width="853" height="315">`
];
for (const expected of scenarios) {
const html = /*html*/`<p>${expected}</p>`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
}
});
it("preserves figures", () => {
const html = /*html*/trimIndentation`\
<figure class="image" style="width:53.44%;">
<img style="aspect-ratio:991/403;" src="Jump to Note_image.png" width="991"
height="403">
</figure>
`;
const expected = `<figure class="image" style="width:53.44%;"><img style="aspect-ratio:991/403;" src="Jump to Note_image.png" width="991" height="403"></figure>`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("converts inline math expressions into proper Markdown syntax", () => {
const html = /*html*/String.raw`<span class="math-tex">\(H(X, Y) = \sum_{i=1}^{M} \sum_{j=1}^{L} p(x_i, y_j) \log_2 \frac{1}{p(x_i, y_j)} = - \sum_{i=1}^{M} \sum_{j=1}^{L} p(x_i, y_j) \log_2 p(x_i, y_j) \frac{\text{bits}}{\text{symbol}}\)</span></span>`;
const expected = String.raw`$H(X, Y) = \sum_{i=1}^{M} \sum_{j=1}^{L} p(x_i, y_j) \log_2 \frac{1}{p(x_i, y_j)} = - \sum_{i=1}^{M} \sum_{j=1}^{L} p(x_i, y_j) \log_2 p(x_i, y_j) \frac{\text{bits}}{\text{symbol}}$`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("converts display math expressions into proper Markdown syntax", () => {
const html = /*html*/String.raw`<span class="math-tex">\[H(X, Y) = \sum_{i=1}^{M} \sum_{j=1}^{L} p(x_i, y_j) \log_2 \frac{1}{p(x_i, y_j)} = - \sum_{i=1}^{M} \sum_{j=1}^{L} p(x_i, y_j) \log_2 p(x_i, y_j) \frac{\text{bits}}{\text{symbol}}\]</span></span>`;
const expected = String.raw`$$H(X, Y) = \sum_{i=1}^{M} \sum_{j=1}^{L} p(x_i, y_j) \log_2 \frac{1}{p(x_i, y_j)} = - \sum_{i=1}^{M} \sum_{j=1}^{L} p(x_i, y_j) \log_2 p(x_i, y_j) \frac{\text{bits}}{\text{symbol}}$$`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("does not generate additional spacing when exporting lists with paragraph", () => {
const html = trimIndentation`\
<ul>
<li><a href="https://github.com/JYC333">@JYC333</a> made their first contribution
in <a href="https://github.com/TriliumNext/Notes/pull/294">#294</a>
</li>
<li>
<p><a href="https://github.com/TriliumNext/Notes/issues/375">Note Tooltip isn't removed when clicking on internal trilium link in read-only mode</a>
</p>
</li>
<li>
<p><a href="https://github.com/TriliumNext/Notes/issues/384">Calendar dropdown won't close if click/right-click other button that open notes from launcher bar</a>
</p>
</li>
</ul>
`;
const expected = trimIndentation`\
* [@JYC333](https://github.com/JYC333) made their first contribution in [#294](https://github.com/TriliumNext/Notes/pull/294)
* [Note Tooltip isn't removed when clicking on internal trilium link in read-only mode](https://github.com/TriliumNext/Notes/issues/375)
* [Calendar dropdown won't close if click/right-click other button that open notes from launcher bar](https://github.com/TriliumNext/Notes/issues/384)`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("preserves include note", () => {
const html = /*html*/`<section class="include-note" data-note-id="i4A5g9iOg9I0" data-box-size="full"> </section>`;
const expected = /*markdown*/`<section class="include-note" data-note-id="i4A5g9iOg9I0" data-box-size="full">&nbsp;</section>`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
it("exports todo lists properly", () => {
const html = trimIndentation/*html*/`\
<ul class="todo-list">
<li>
<label class="todo-list__label">
<input type="checkbox" checked="checked" disabled="disabled"><span class="todo-list__label__description">Hello</span>
</label>
</li>
<li>
<label class="todo-list__label">
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description">World</span>
</label>
</li>
</ul>
`;
const expected = trimIndentation`\
- [x] Hello
- [ ] World`;
expect(markdownExportService.toMarkdown(html)).toBe(expected);
});
});

View File

@@ -0,0 +1,283 @@
"use strict";
import TurndownService, { type Rule } from "turndown";
import { gfm } from "@triliumnext/turndown-plugin-gfm";
let instance: TurndownService | 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: TurndownService.Rule = {
filter: function (node, options) {
return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild !== null && node.firstChild.nodeName === "CODE";
},
replacement: function (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 TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
blankReplacement(content, node, options) {
if (node.nodeName === "SECTION" && (node as HTMLElement).classList.contains("include-note")) {
return (node as HTMLElement).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" ]);
}
return instance.turndown(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, function (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: TurndownService.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: TurndownService.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: function (node, options) {
return (
options.linkStyle === 'inlined' &&
node.nodeName === 'A' &&
!!node.getAttribute('href')
)
},
replacement: function (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'
},
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') {
var start = parent.getAttribute('start')
var 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

@@ -0,0 +1,99 @@
"use strict";
import { getContentDisposition, stripTags } from "../utils.js";
import becca from "../../becca/becca.js";
import type TaskContext from "../task_context.js";
import type BBranch from "../../becca/entities/bbranch.js";
import type { Response } from "express";
function exportToOpml(taskContext: TaskContext, branch: BBranch, version: string, res: Response) {
if (!["1.0", "2.0"].includes(version)) {
throw new Error(`Unrecognized OPML version ${version}`);
}
const opmlVersion = parseInt(version);
const note = branch.getNote();
function exportNoteInner(branchId: string) {
const branch = becca.getBranch(branchId);
if (!branch) {
throw new Error("Unable to find branch.");
}
const note = branch.getNote();
if (!note) {
throw new Error("Unable to find note.");
}
if (note.hasOwnedLabel("excludeFromExport")) {
return;
}
const title = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.title}`;
if (opmlVersion === 1) {
const preparedTitle = escapeXmlAttribute(title);
const preparedContent = note.hasStringContent() ? prepareText(note.getContent() as string) : "";
res.write(`<outline title="${preparedTitle}" text="${preparedContent}">\n`);
} else if (opmlVersion === 2) {
const preparedTitle = escapeXmlAttribute(title);
const preparedContent = note.hasStringContent() ? escapeXmlAttribute(note.getContent() as string) : "";
res.write(`<outline text="${preparedTitle}" _note="${preparedContent}">\n`);
} else {
throw new Error(`Unrecognized OPML version ${opmlVersion}`);
}
taskContext.increaseProgressCount();
for (const child of note.getChildBranches()) {
if (child?.branchId) {
exportNoteInner(child.branchId);
}
}
res.write("</outline>");
}
const filename = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.title}.opml`;
res.setHeader("Content-Disposition", getContentDisposition(filename));
res.setHeader("Content-Type", "text/x-opml");
res.write(`<?xml version="1.0" encoding="UTF-8"?>
<opml version="${version}">
<head>
<title>Trilium export</title>
</head>
<body>`);
if (branch.branchId) {
exportNoteInner(branch.branchId);
}
res.write(`</body>
</opml>`);
res.end();
taskContext.taskSucceeded();
}
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 escaped = escapeXmlAttribute(stripped);
return escaped.replace(/\n/g, "&#10;");
}
function escapeXmlAttribute(text: string) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
export default {
exportToOpml
};

View File

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import BNote from "../../becca/entities/bnote.js";
import { mapByNoteType } from "./single.js";
describe("Note type mappings", () => {
it("supports mermaid note", () => {
const note = new BNote({
type: "mermaid",
title: "New note"
});
expect(mapByNoteType(note, "", "html")).toMatchObject({
extension: "mermaid",
mime: "text/vnd.mermaid"
});
});
});

View File

@@ -0,0 +1,138 @@
"use strict";
import mimeTypes from "mime-types";
import html from "html";
import { getContentDisposition, escapeHtml } from "../utils.js";
import mdService from "./markdown.js";
import becca from "../../becca/becca.js";
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";
function exportSingleNote(taskContext: TaskContext, branch: BBranch, format: "html" | "markdown", res: Response) {
const note = branch.getNote();
if (note.type === "image" || note.type === "file") {
return [400, `Note type '${note.type}' cannot be exported as single file.`];
}
if (format !== "html" && format !== "markdown") {
return [400, `Unrecognized format '${format}'`];
}
const { payload, extension, mime } = mapByNoteType(note, note.getContent(), format);
const fileName = `${note.title}.${extension}`;
res.setHeader("Content-Disposition", getContentDisposition(fileName));
res.setHeader("Content-Type", `${mime}; charset=UTF-8`);
res.send(payload);
taskContext.increaseProgressCount();
taskContext.taskSucceeded();
}
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: "html" | "markdown") {
let payload, extension, mime;
if (typeof content !== "string") {
throw new Error("Unsupported content type for export.");
}
if (note.type === "text") {
if (format === "html") {
content = inlineAttachments(content);
if (!content.toLowerCase().includes("<html")) {
content = `<html><head><meta charset="utf-8"></head><body>${content}</body></html>`;
}
payload = content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content;
extension = "html";
mime = "text/html";
} else if (format === "markdown") {
payload = mdService.toMarkdown(content);
extension = "md";
mime = "text/x-markdown";
}
} else if (note.type === "code") {
payload = content;
extension = mimeTypes.extension(note.mime) || "code";
mime = note.mime;
} else if (note.type === "canvas") {
payload = content;
extension = "excalidraw";
mime = "application/json";
} else if (note.type === "mermaid") {
payload = content;
extension = "mermaid";
mime = "text/vnd.mermaid";
} else if (note.type === "relationMap" || note.type === "search") {
payload = content;
extension = "json";
mime = "application/json";
}
return { payload, extension, mime };
}
function inlineAttachments(content: string) {
content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/?[^"]+"/g, (match, noteId) => {
const note = becca.getNote(noteId);
if (!note || !note.mime.startsWith("image/")) {
return match;
}
const imageContent = note.getContent();
if (!Buffer.isBuffer(imageContent)) {
return match;
}
const base64Content = imageContent.toString("base64");
const srcValue = `data:${note.mime};base64,${base64Content}`;
return `src="${srcValue}"`;
});
content = content.replace(/src="[^"]*api\/attachments\/([a-zA-Z0-9_]+)\/image\/?[^"]+"/g, (match, attachmentId) => {
const attachment = becca.getAttachment(attachmentId);
if (!attachment || !attachment.mime.startsWith("image/")) {
return match;
}
const attachmentContent = attachment.getContent();
if (!Buffer.isBuffer(attachmentContent)) {
return match;
}
const base64Content = attachmentContent.toString("base64");
const srcValue = `data:${attachment.mime};base64,${base64Content}`;
return `src="${srcValue}"`;
});
content = content.replace(/href="[^"]*#root[^"]*attachmentId=([a-zA-Z0-9_]+)\/?"/g, (match, attachmentId) => {
const attachment = becca.getAttachment(attachmentId);
if (!attachment) {
return match;
}
const attachmentContent = attachment.getContent();
if (!Buffer.isBuffer(attachmentContent)) {
return match;
}
const base64Content = attachmentContent.toString("base64");
const hrefValue = `data:${attachment.mime};base64,${base64Content}`;
return `href="${hrefValue}" download="${escapeHtml(attachment.title)}"`;
});
return content;
}
export default {
exportSingleNote
};

View File

@@ -0,0 +1,631 @@
"use strict";
import html from "html";
import dateUtils from "../date_utils.js";
import path from "path";
import mimeTypes from "mime-types";
import mdService from "./markdown.js";
import packageInfo from "../../../package.json" with { type: "json" };
import { getContentDisposition, escapeHtml } from "../utils.js";
import protectedSessionService from "../protected_session.js";
import sanitize from "sanitize-filename";
import fs from "fs";
import becca from "../../becca/becca.js";
import archiver from "archiver";
import log from "../log.js";
import TaskContext from "../task_context.js";
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 type { Response } from "express";
import { RESOURCE_DIR } from "../resource_dir.js";
import type { NoteMetaFile } from "../meta/note_meta.js";
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
export interface AdvancedExportOptions {
/**
* If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own <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, 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`);
}
const archive = archiver("zip", {
zlib: { level: 9 } // Sets the compression level.
});
const noteIdToMeta: Record<string, NoteMeta> = {};
function getUniqueFilename(existingFileNames: Record<string, number>, fileName: string) {
const lcFileName = fileName.toLowerCase();
if (lcFileName in existingFileNames) {
let index;
let newName;
do {
index = existingFileNames[lcFileName]++;
newName = `${index}_${lcFileName}`;
} while (newName in existingFileNames);
return `${index}_${fileName}`;
} else {
existingFileNames[lcFileName] = 1;
return fileName;
}
}
function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record<string, number>): string {
let fileName = baseFileName.trim();
// Crop fileName to avoid its length exceeding 30 and prevent cutting into the extension.
if (fileName.length > 30) {
// We use regex to match the extension to preserve multiple dots in extensions (e.g. .tar.gz).
let match = fileName.match(/(\.[a-zA-Z0-9_.!#-]+)$/);
let ext = match ? match[0] : "";
// Crop the extension if extension length exceeds 30
const croppedExt = ext.slice(-30);
// Crop the file name section and append the cropped extension
fileName = fileName.slice(0, 30 - croppedExt.length) + croppedExt;
}
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";
}
}
// 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);
}
function createNoteMeta(branch: BBranch, parentMeta: Partial<NoteMeta>, existingFileNames: Record<string, number>): NoteMeta | null {
const note = branch.getNote();
if (note.hasOwnedLabel("excludeFromExport")) {
return null;
}
const title = note.getTitleOrProtected();
const completeTitle = branch.prefix ? `${branch.prefix} - ${title}` : title;
let baseFileName = sanitize(completeTitle);
if (baseFileName.length > 200) {
// the actual limit is 256 bytes(!) but let's be conservative
baseFileName = baseFileName.substr(0, 200);
}
if (!parentMeta.notePath) {
throw new Error("Missing parent note path.");
}
const notePath = parentMeta.notePath.concat([note.noteId]);
if (note.noteId in noteIdToMeta) {
const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === "html" ? "html" : "md"}`);
const meta: NoteMeta = {
isClone: true,
noteId: note.noteId,
notePath: notePath,
title: note.getTitleOrProtected(),
prefix: branch.prefix,
dataFileName: fileName,
type: "text", // export will have text description
format: format
};
return meta;
}
const meta: Partial<NoteMeta> = {};
meta.isClone = false;
meta.noteId = note.noteId;
meta.notePath = notePath;
meta.title = note.getTitleOrProtected();
meta.notePosition = branch.notePosition;
meta.prefix = branch.prefix;
meta.isExpanded = branch.isExpanded;
meta.type = note.type;
meta.mime = note.mime;
meta.attributes = note.getOwnedAttributes().map((attribute) => {
const attrMeta: AttributeMeta = {
type: attribute.type,
name: attribute.name,
value: attribute.value,
isInheritable: attribute.isInheritable,
position: attribute.position
};
return attrMeta;
});
taskContext.increaseProgressCount();
if (note.type === "text") {
meta.format = format;
}
noteIdToMeta[note.noteId] = meta as NoteMeta;
// sort children for having a stable / reproducible export format
note.sortChildren();
const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden");
const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable();
// if it's a leaf, then we'll export it even if it's empty
if (available && (note.getContent().length > 0 || childBranches.length === 0)) {
meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames);
}
const attachments = note.getAttachments();
meta.attachments = attachments
.toSorted((a, b) => ((a.attachmentId ?? "").localeCompare(b.attachmentId ?? "", "en") ?? 1))
.map((attachment) => {
const attMeta: AttachmentMeta = {
attachmentId: attachment.attachmentId,
title: attachment.title,
role: attachment.role,
mime: attachment.mime,
position: attachment.position,
dataFileName: getDataFileName(null, attachment.mime, baseFileName + "_" + attachment.title, existingFileNames)
};
return attMeta;
});
if (childBranches.length > 0) {
meta.dirFileName = getUniqueFilename(existingFileNames, baseFileName);
meta.children = [];
// namespace is shared by children in the same note
const childExistingNames = {};
for (const childBranch of childBranches) {
if (!childBranch) {
continue;
}
const note = createNoteMeta(childBranch, meta as NoteMeta, childExistingNames);
// can be undefined if export is disabled for this note
if (note) {
meta.children.push(note);
}
}
}
return meta as NoteMeta;
}
function getNoteTargetUrl(targetNoteId: string, sourceMeta: NoteMeta): string | null {
const targetMeta = noteIdToMeta[targetNoteId];
if (!targetMeta || !targetMeta.notePath || !sourceMeta.notePath) {
return null;
}
const targetPath = targetMeta.notePath.slice();
const sourcePath = sourceMeta.notePath.slice();
// > 1 for the edge case that targetPath and sourcePath are exact same (a link to itself)
while (targetPath.length > 1 && sourcePath.length > 1 && targetPath[0] === sourcePath[0]) {
targetPath.shift();
sourcePath.shift();
}
let url = "../".repeat(sourcePath.length - 1);
for (let i = 0; i < targetPath.length - 1; i++) {
const meta = noteIdToMeta[targetPath[i]];
if (meta.dirFileName) {
url += `${encodeURIComponent(meta.dirFileName)}/`;
}
}
const meta = noteIdToMeta[targetPath[targetPath.length - 1]];
// link can target note which is only "folder-note" and as such, will not have a file in an export
url += encodeURIComponent(meta.dataFileName || meta.dirFileName || "");
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);
return url ? `src="${url}"` : match;
});
content = content.replace(/src="[^"]*api\/attachments\/([a-zA-Z0-9_]+)\/image\/[^"]*"/g, (match, targetAttachmentId) => {
const url = findAttachment(targetAttachmentId);
return url ? `src="${url}"` : match;
});
content = content.replace(/href="[^"]*#root[^"]*attachmentId=([a-zA-Z0-9_]+)\/?"/g, (match, targetAttachmentId) => {
const url = findAttachment(targetAttachmentId);
return url ? `href="${url}"` : match;
});
content = content.replace(/href="[^"]*#root[a-zA-Z0-9_\/]*\/([a-zA-Z0-9_]+)[^"]*"/g, (match, targetNoteId) => {
const url = getNoteTargetUrl(targetNoteId, noteMeta);
return url ? `href="${url}"` : match;
});
return content;
function findAttachment(targetAttachmentId: string) {
let url;
const attachmentMeta = (noteMeta.attachments || []).find((attMeta) => attMeta.attachmentId === targetAttachmentId);
if (attachmentMeta) {
// easy job here, because attachment will be in the same directory as the note's data file.
url = attachmentMeta.dataFileName;
} else {
log.info(`Could not find attachment meta object for attachmentId '${targetAttachmentId}'`);
}
return url;
}
}
function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer {
if (["html", "markdown"].includes(noteMeta?.format || "")) {
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.");
}
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;
}
}
function saveNote(noteMeta: NoteMeta, filePathPrefix: string) {
log.info(`Exporting note '${noteMeta.noteId}'`);
if (!noteMeta.noteId || !noteMeta.title) {
throw new Error("Missing note meta.");
}
if (noteMeta.isClone) {
const targetUrl = getNoteTargetUrl(noteMeta.noteId, noteMeta);
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);
archive.append(content, { name: filePathPrefix + noteMeta.dataFileName });
return;
}
const note = becca.getNote(noteMeta.noteId);
if (!note) {
throw new Error("Unable to find note.");
}
if (!note.utcDateModified) {
throw new Error("Unable to find modification date.");
}
if (noteMeta.dataFileName) {
const content = prepareContent(noteMeta.title, note.getContent(), noteMeta);
archive.append(content, {
name: filePathPrefix + noteMeta.dataFileName,
date: dateUtils.parseDateTime(note.utcDateModified)
});
}
taskContext.increaseProgressCount();
for (const attachmentMeta of noteMeta.attachments || []) {
if (!attachmentMeta.attachmentId) {
continue;
}
const attachment = note.getAttachmentById(attachmentMeta.attachmentId);
const content = attachment.getContent();
archive.append(content, {
name: filePathPrefix + attachmentMeta.dataFileName,
date: dateUtils.parseDateTime(note.utcDateModified)
});
}
if (noteMeta.children?.length || 0 > 0) {
const directoryPath = filePathPrefix + noteMeta.dirFileName;
// create directory
archive.append("", { name: `${directoryPath}/`, date: dateUtils.parseDateTime(note.utcDateModified) });
for (const childMeta of noteMeta.children || []) {
saveNote(childMeta, `${directoryPath}/`);
}
}
}
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 });
}
function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) {
let firstNonEmptyNote;
let curMeta = rootMeta;
if (!indexMeta.dataFileName) {
return;
}
while (!firstNonEmptyNote) {
if (curMeta.dataFileName && curMeta.noteId) {
firstNonEmptyNote = getNoteTargetUrl(curMeta.noteId, rootMeta);
}
if (curMeta.children && curMeta.children.length > 0) {
curMeta = curMeta.children[0];
} else {
break;
}
}
const fullHtml = `<!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 cssContent = fs.readFileSync(`${RESOURCE_DIR}/libraries/ckeditor/ckeditor-content.css`);
archive.append(cssContent, { name: cssMeta.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.");
}
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) => {
if (attr.type !== "relation") {
return true;
} else if (attr.value in noteIdToMeta) {
return true;
} else if (attr.value === "root" || attr.value?.startsWith("_")) {
// relations to "named" noteIds can be preserved
return true;
} else {
return false;
}
});
}
if (!rootMeta) {
// corner case of disabled export for exported note
if ("sendStatus" in res) {
res.sendStatus(400);
}
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()}.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();
}
async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
const fileOutputStream = fs.createWriteStream(zipFilePath);
const taskContext = new TaskContext("no-progress-reporting");
const note = becca.getNote(noteId);
if (!note) {
throw new ValidationError(`Note ${noteId} not found.`);
}
await exportToZip(taskContext, note.getParentBranches()[0], format, fileOutputStream, false, zipExportOptions);
log.info(`Exported '${noteId}' with format '${format}' to '${zipFilePath}'`);
}
export default {
exportToZip,
exportToZipFile
};