mirror of
https://github.com/zadam/trilium.git
synced 2025-11-18 11:10:41 +01:00
chore(nx): move all monorepo-style in subfolder for processing
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
export interface File {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
buffer: string | Buffer;
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import sax from "sax";
|
||||
import stream from "stream";
|
||||
import { Throttle } from "stream-throttle";
|
||||
import log from "../log.js";
|
||||
import { md5, escapeHtml, fromBase64 } from "../utils.js";
|
||||
import sql from "../sql.js";
|
||||
import noteService from "../notes.js";
|
||||
import imageService from "../image.js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import htmlSanitizer from "../html_sanitizer.js";
|
||||
import sanitizeAttributeName from "../sanitize_attribute_name.js";
|
||||
import type TaskContext from "../task_context.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type { File } from "./common.js";
|
||||
import type { AttributeType } from "@triliumnext/commons";
|
||||
|
||||
/**
|
||||
* date format is e.g. 20181121T193703Z or 2013-04-14T16:19:00.000Z (Mac evernote, see #3496)
|
||||
* @returns trilium date format, e.g. 2013-04-14 16:19:00.000Z
|
||||
*/
|
||||
function parseDate(text: string) {
|
||||
// convert ISO format to the "20181121T193703Z" format
|
||||
text = text.replace(/[-:]/g, "");
|
||||
|
||||
// insert - and : to convert it to trilium format
|
||||
text = text.substr(0, 4) + "-" + text.substr(4, 2) + "-" + text.substr(6, 2) + " " + text.substr(9, 2) + ":" + text.substr(11, 2) + ":" + text.substr(13, 2) + ".000Z";
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
interface Attribute {
|
||||
type: AttributeType;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Resource {
|
||||
title: string;
|
||||
content?: Buffer | string;
|
||||
mime?: string;
|
||||
attributes: Attribute[];
|
||||
}
|
||||
|
||||
interface Note {
|
||||
title: string;
|
||||
attributes: Attribute[];
|
||||
utcDateCreated: string;
|
||||
utcDateModified: string;
|
||||
noteId: string;
|
||||
blobId: string;
|
||||
content: string;
|
||||
resources: Resource[];
|
||||
}
|
||||
|
||||
let note: Partial<Note> = {};
|
||||
let resource: Resource;
|
||||
|
||||
function importEnex(taskContext: TaskContext, file: File, parentNote: BNote): Promise<BNote> {
|
||||
const saxStream = sax.createStream(true);
|
||||
|
||||
const rootNoteTitle = file.originalname.toLowerCase().endsWith(".enex") ? file.originalname.substr(0, file.originalname.length - 5) : file.originalname;
|
||||
|
||||
// root note is new note into all ENEX/notebook's notes will be imported
|
||||
const rootNote = noteService.createNewNote({
|
||||
parentNoteId: parentNote.noteId,
|
||||
title: rootNoteTitle,
|
||||
content: "",
|
||||
type: "text",
|
||||
mime: "text/html",
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
}).note;
|
||||
|
||||
function extractContent(content: string) {
|
||||
const openingNoteIndex = content.indexOf("<en-note>");
|
||||
|
||||
if (openingNoteIndex !== -1) {
|
||||
content = content.substr(openingNoteIndex + 9);
|
||||
}
|
||||
|
||||
const closingNoteIndex = content.lastIndexOf("</en-note>");
|
||||
|
||||
if (closingNoteIndex !== -1) {
|
||||
content = content.substr(0, closingNoteIndex);
|
||||
}
|
||||
|
||||
content = content.trim();
|
||||
|
||||
// workaround for https://github.com/ckeditor/ckeditor5-list/issues/116
|
||||
content = content.replace(/<li>\s*<div>/g, "<li>");
|
||||
content = content.replace(/<\/div>\s*<\/li>/g, "</li>");
|
||||
|
||||
// workaround for https://github.com/ckeditor/ckeditor5-list/issues/115
|
||||
content = content.replace(/<ul>\s*<ul>/g, "<ul><li><ul>");
|
||||
content = content.replace(/<\/li>\s*<ul>/g, "<ul>");
|
||||
content = content.replace(/<\/ul>\s*<\/ul>/g, "</ul></li></ul>");
|
||||
content = content.replace(/<\/ul>\s*<li>/g, "</ul></li><li>");
|
||||
|
||||
content = content.replace(/<ol>\s*<ol>/g, "<ol><li><ol>");
|
||||
content = content.replace(/<\/li>\s*<ol>/g, "<ol>");
|
||||
content = content.replace(/<\/ol>\s*<\/ol>/g, "</ol></li></ol>");
|
||||
content = content.replace(/<\/ol>\s*<li>/g, "</ol></li><li>");
|
||||
|
||||
// Replace en-todo with unicode ballot box
|
||||
content = content.replace(/<en-todo\s+checked="true"\s*\/>/g, "\u2611 ");
|
||||
content = content.replace(/<en-todo(\s+checked="false")?\s*\/>/g, "\u2610 ");
|
||||
|
||||
// Replace OneNote converted checkboxes with unicode ballot box based
|
||||
// on known hash of checkboxes for regular, p1, and p2 checkboxes
|
||||
content = content.replace(
|
||||
/<en-media alt="To Do( priority [12])?" hash="(74de5d3d1286f01bac98d32a09f601d9|4a19d3041585e11643e808d68dd3e72f|8e17580123099ac6515c3634b1f6f9a1)"( type="[a-z\/]*"| width="\d+"| height="\d+")*\/>/g,
|
||||
"\u2610 "
|
||||
);
|
||||
content = content.replace(
|
||||
/<en-media alt="To Do( priority [12])?" hash="(5069b775461e471a47ce04ace6e1c6ae|7912ee9cec35fc3dba49edb63a9ed158|3a05f4f006a6eaf2627dae5ed8b8013b)"( type="[a-z\/]*"| width="\d+"| height="\d+")*\/>/g,
|
||||
"\u2611 "
|
||||
);
|
||||
|
||||
content = htmlSanitizer.sanitize(content);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const path: string[] = [];
|
||||
|
||||
function getCurrentTag() {
|
||||
if (path.length >= 1) {
|
||||
return path[path.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviousTag() {
|
||||
if (path.length >= 2) {
|
||||
return path[path.length - 2];
|
||||
}
|
||||
}
|
||||
|
||||
saxStream.on("error", (e) => {
|
||||
// unhandled errors will throw, since this is a proper node event emitter.
|
||||
log.error(`error when parsing ENEX file: ${e}`);
|
||||
// clear the error
|
||||
(saxStream._parser as any).error = null;
|
||||
saxStream._parser.resume();
|
||||
});
|
||||
|
||||
saxStream.on("text", (text) => {
|
||||
const currentTag = getCurrentTag();
|
||||
const previousTag = getPreviousTag();
|
||||
|
||||
if (previousTag === "note-attributes") {
|
||||
let labelName = currentTag;
|
||||
|
||||
if (labelName === "source-url") {
|
||||
labelName = "pageUrl";
|
||||
}
|
||||
|
||||
labelName = sanitizeAttributeName(labelName || "");
|
||||
|
||||
if (note.attributes) {
|
||||
note.attributes.push({
|
||||
type: "label",
|
||||
name: labelName,
|
||||
value: text
|
||||
});
|
||||
}
|
||||
} else if (previousTag === "resource-attributes") {
|
||||
if (currentTag === "file-name") {
|
||||
resource.attributes.push({
|
||||
type: "label",
|
||||
name: "originalFileName",
|
||||
value: text
|
||||
});
|
||||
|
||||
resource.title = text;
|
||||
} else if (currentTag === "source-url") {
|
||||
resource.attributes.push({
|
||||
type: "label",
|
||||
name: "pageUrl",
|
||||
value: text
|
||||
});
|
||||
}
|
||||
} else if (previousTag === "resource") {
|
||||
if (currentTag === "data") {
|
||||
text = text.replace(/\s/g, "");
|
||||
|
||||
// resource can be chunked into multiple events: https://github.com/zadam/trilium/issues/3424
|
||||
// it would probably make sense to do this in a more global way since it can in theory affect any field,
|
||||
// not just data
|
||||
resource.content = (resource.content || "") + text;
|
||||
} else if (currentTag === "mime") {
|
||||
resource.mime = text.toLowerCase();
|
||||
}
|
||||
} else if (previousTag === "note") {
|
||||
if (currentTag === "title") {
|
||||
note.title = text;
|
||||
} else if (currentTag === "created") {
|
||||
note.utcDateCreated = parseDate(text);
|
||||
} else if (currentTag === "updated") {
|
||||
note.utcDateModified = parseDate(text);
|
||||
} else if (currentTag === "tag" && note.attributes) {
|
||||
note.attributes.push({
|
||||
type: "label",
|
||||
name: sanitizeAttributeName(text),
|
||||
value: ""
|
||||
});
|
||||
}
|
||||
// unknown tags are just ignored
|
||||
}
|
||||
});
|
||||
|
||||
saxStream.on("attribute", (attr) => {
|
||||
// an attribute. attr has "name" and "value"
|
||||
});
|
||||
|
||||
saxStream.on("opentag", (tag) => {
|
||||
path.push(tag.name);
|
||||
|
||||
if (tag.name === "note") {
|
||||
note = {
|
||||
content: "",
|
||||
// it's an array, not a key-value object because we don't know if attributes can be duplicated
|
||||
attributes: [],
|
||||
resources: []
|
||||
};
|
||||
} else if (tag.name === "resource") {
|
||||
resource = {
|
||||
title: "resource",
|
||||
attributes: []
|
||||
};
|
||||
|
||||
if (note.resources) {
|
||||
note.resources.push(resource);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateDates(note: BNote, utcDateCreated?: string, utcDateModified?: string) {
|
||||
// it's difficult to force custom dateCreated and dateModified to Note entity, so we do it post-creation with SQL
|
||||
sql.execute(
|
||||
`
|
||||
UPDATE notes
|
||||
SET dateCreated = ?,
|
||||
utcDateCreated = ?,
|
||||
dateModified = ?,
|
||||
utcDateModified = ?
|
||||
WHERE noteId = ?`,
|
||||
[utcDateCreated, utcDateCreated, utcDateModified, utcDateModified, note.noteId]
|
||||
);
|
||||
|
||||
sql.execute(
|
||||
`
|
||||
UPDATE blobs
|
||||
SET utcDateModified = ?
|
||||
WHERE blobId = ?`,
|
||||
[utcDateModified, note.blobId]
|
||||
);
|
||||
}
|
||||
|
||||
function saveNote() {
|
||||
// make a copy because stream continues with the next call and note gets overwritten
|
||||
let { title, content, attributes, resources, utcDateCreated, utcDateModified } = note;
|
||||
|
||||
if (!title || !content) {
|
||||
throw new Error("Missing title or content for note.");
|
||||
}
|
||||
|
||||
content = extractContent(content);
|
||||
|
||||
const noteEntity = noteService.createNewNote({
|
||||
parentNoteId: rootNote.noteId,
|
||||
title,
|
||||
content,
|
||||
utcDateCreated,
|
||||
type: "text",
|
||||
mime: "text/html",
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
}).note;
|
||||
|
||||
for (const attr of attributes || []) {
|
||||
noteEntity.addAttribute(attr.type, attr.name, attr.value);
|
||||
}
|
||||
|
||||
utcDateCreated = utcDateCreated || noteEntity.utcDateCreated;
|
||||
// sometime date modified is not present in ENEX, then use date created
|
||||
utcDateModified = utcDateModified || utcDateCreated;
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
for (const resource of resources || []) {
|
||||
if (!resource.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof resource.content === "string") {
|
||||
resource.content = fromBase64(resource.content);
|
||||
}
|
||||
|
||||
const hash = md5(resource.content);
|
||||
|
||||
// skip all checked/unchecked checkboxes from OneNote
|
||||
if (
|
||||
[
|
||||
"74de5d3d1286f01bac98d32a09f601d9",
|
||||
"4a19d3041585e11643e808d68dd3e72f",
|
||||
"8e17580123099ac6515c3634b1f6f9a1",
|
||||
"5069b775461e471a47ce04ace6e1c6ae",
|
||||
"7912ee9cec35fc3dba49edb63a9ed158",
|
||||
"3a05f4f006a6eaf2627dae5ed8b8013b"
|
||||
].includes(hash)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaRegex = new RegExp(`<en-media [^>]*hash="${hash}"[^>]*>`, "g");
|
||||
|
||||
resource.mime = resource.mime || "application/octet-stream";
|
||||
|
||||
const createFileNote = () => {
|
||||
const resourceNote = noteService.createNewNote({
|
||||
parentNoteId: noteEntity.noteId,
|
||||
title: resource.title,
|
||||
content: resource.content ?? "",
|
||||
type: "file",
|
||||
mime: resource.mime,
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
}).note;
|
||||
|
||||
for (const attr of resource.attributes) {
|
||||
resourceNote.addAttribute(attr.type, attr.name, attr.value);
|
||||
}
|
||||
|
||||
updateDates(resourceNote, utcDateCreated, utcDateModified);
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
const resourceLink = `<a href="#root/${resourceNote.noteId}">${escapeHtml(resource.title)}</a>`;
|
||||
|
||||
content = (content || "").replace(mediaRegex, resourceLink);
|
||||
};
|
||||
|
||||
if (resource.mime && resource.mime.startsWith("image/")) {
|
||||
try {
|
||||
const originalName = resource.title && resource.title !== "resource" ? resource.title : `image.${resource.mime.substr(6)}`; // default if real name is not present
|
||||
|
||||
const attachment = imageService.saveImageToAttachment(noteEntity.noteId, resource.content, originalName, !!taskContext.data?.shrinkImages);
|
||||
|
||||
const encodedTitle = encodeURIComponent(attachment.title);
|
||||
const url = `api/attachments/${attachment.attachmentId}/image/${encodedTitle}`;
|
||||
const imageLink = `<img src="${url}">`;
|
||||
|
||||
content = content.replace(mediaRegex, imageLink);
|
||||
|
||||
if (!content.includes(imageLink)) {
|
||||
// if there wasn't any match for the reference, we'll add the image anyway,
|
||||
// otherwise the image would be removed since no note would include it
|
||||
content += imageLink;
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error(`error when saving image from ENEX file: ${e.message}`);
|
||||
createFileNote();
|
||||
}
|
||||
} else {
|
||||
createFileNote();
|
||||
}
|
||||
}
|
||||
|
||||
content = htmlSanitizer.sanitize(content);
|
||||
|
||||
// save updated content with links to files/images
|
||||
noteEntity.setContent(content);
|
||||
|
||||
noteService.asyncPostProcessContent(noteEntity, content);
|
||||
|
||||
updateDates(noteEntity, utcDateCreated, utcDateModified);
|
||||
}
|
||||
|
||||
saxStream.on("closetag", (tag) => {
|
||||
path.pop();
|
||||
|
||||
if (tag === "note") {
|
||||
saveNote();
|
||||
}
|
||||
});
|
||||
|
||||
saxStream.on("opencdata", () => {
|
||||
//console.log("opencdata");
|
||||
});
|
||||
|
||||
saxStream.on("cdata", (text) => {
|
||||
note.content += text;
|
||||
});
|
||||
|
||||
saxStream.on("closecdata", () => {
|
||||
//console.log("closecdata");
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// resolve only when we parse the whole document AND saving of all notes have been finished
|
||||
saxStream.on("end", () => resolve(rootNote));
|
||||
|
||||
const bufferStream = new stream.PassThrough();
|
||||
bufferStream.end(file.buffer);
|
||||
|
||||
bufferStream
|
||||
// rate limiting to improve responsiveness during / after import
|
||||
.pipe(new Throttle({ rate: 500000 }))
|
||||
.pipe(saxStream);
|
||||
});
|
||||
}
|
||||
|
||||
export default { importEnex };
|
||||
@@ -1,244 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { trimIndentation } from "@triliumnext/commons";
|
||||
import markdownService from "./markdown.js";
|
||||
|
||||
describe("markdown", () => {
|
||||
it("rewrites language of known language tags", () => {
|
||||
const conversionTable = {
|
||||
"nginx": "language-text-x-nginx-conf",
|
||||
"diff": "language-text-x-diff",
|
||||
"javascript": "language-application-javascript-env-backend",
|
||||
"css": "language-text-css",
|
||||
"mips": "language-text-x-asm-mips"
|
||||
};
|
||||
|
||||
for (const [ input, output ] of Object.entries(conversionTable)) {
|
||||
const result = markdownService.renderToHtml(trimIndentation`\
|
||||
\`\`\`${input}
|
||||
Hi
|
||||
\`\`\`
|
||||
`, "title");
|
||||
expect(result).toBe(trimIndentation`\
|
||||
<pre><code class="${output}">Hi</code></pre>`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites language of unknown language tags", () => {
|
||||
const result = markdownService.renderToHtml(trimIndentation`\
|
||||
\`\`\`unknownlanguage
|
||||
Hi
|
||||
\`\`\`
|
||||
`, "title");
|
||||
expect(result).toBe(trimIndentation`\
|
||||
<pre><code class="language-text-x-trilium-auto">Hi</code></pre>`);
|
||||
});
|
||||
|
||||
it("converts h1 heading", () => {
|
||||
const result = markdownService.renderToHtml(trimIndentation`\
|
||||
# Hello
|
||||
## world
|
||||
# another one
|
||||
Hello, world
|
||||
`, "title");
|
||||
expect(result).toBe(`<h2>Hello</h2><h2>world</h2><h2>another one</h2><p>Hello, world</p>`);
|
||||
});
|
||||
|
||||
it("parses duplicate title with escape correctly", () => {
|
||||
const titles = [
|
||||
"What's new",
|
||||
"Node.js, Electron and `better-sqlite3`"
|
||||
];
|
||||
|
||||
for (const title of titles) {
|
||||
const result = markdownService.renderToHtml(trimIndentation`\
|
||||
# ${title}
|
||||
Hi there
|
||||
`, title)
|
||||
expect(result).toBe(`<p>Hi there</p>`);
|
||||
}
|
||||
});
|
||||
|
||||
it("trims unnecessary whitespace", () => {
|
||||
const input = `\
|
||||
## Heading 1
|
||||
|
||||
Title
|
||||
|
||||
\`\`\`
|
||||
code block 1
|
||||
second line 2
|
||||
\`\`\`
|
||||
|
||||
* Hello
|
||||
* world
|
||||
|
||||
1. Hello
|
||||
2. World
|
||||
`;
|
||||
const expected = `\
|
||||
<h2>Heading 1</h2><p>Title</p><pre><code class="language-text-x-trilium-auto">code block 1
|
||||
second line 2</code></pre><ul><li>Hello</li><li>world</li></ul><ol><li>Hello</li><li>World</li></ol>`;
|
||||
expect(markdownService.renderToHtml(input, "Troubleshooting")).toBe(expected);
|
||||
});
|
||||
|
||||
it("imports admonitions properly", () => {
|
||||
const space = " "; // editor config trimming space.
|
||||
const input = trimIndentation`\
|
||||
Before
|
||||
|
||||
> [!NOTE]
|
||||
> This is a note.
|
||||
|
||||
> [!TIP]
|
||||
> This is a tip.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is a very important information.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a caution.
|
||||
|
||||
> [!WARNING]
|
||||
> ## Title goes here
|
||||
>${space}
|
||||
> This is a warning.
|
||||
|
||||
After`;
|
||||
const expected = `<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></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>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("imports images with same outcome as if inserted from CKEditor", () => {
|
||||
const input = "";
|
||||
const expected = `<p><img src="api/attachments/YbkR3wt2zMcA/image/image"></p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("maintains code blocks with XML/HTML", () => {
|
||||
const input = trimIndentation`\
|
||||
Before
|
||||
\`\`\`
|
||||
<application
|
||||
...
|
||||
android:testOnly="false">
|
||||
...
|
||||
</application>
|
||||
\`\`\`
|
||||
After`;
|
||||
const expected = trimIndentation`\
|
||||
<p>Before</p><pre><code class="language-text-x-trilium-auto"><application
|
||||
...
|
||||
android:testOnly="false">
|
||||
...
|
||||
</application></code></pre><p>After</p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("does not escape unneeded characters", () => {
|
||||
const input = `It's important to note that these examples are not natively supported by Trilium out of the box; instead, they demonstrate what you can build within Trilium.`;
|
||||
const expected = `<p>It's important to note that these examples are not natively supported by Trilium out of the box; instead, they demonstrate what you can build within Trilium.</p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("preserves ", () => {
|
||||
const input = `Hello world.`;
|
||||
const expected = /*html*/`<p>Hello world.</p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("converts non-breaking space character to ", () => {
|
||||
const input = `Hello\u00a0world.`;
|
||||
const expected = /*html*/`<p>Hello world.</p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("supports normal links", () => {
|
||||
const input = `[Google](https://www.google.com)`;
|
||||
const expected = /*html*/`<p><a href="https://www.google.com">Google</a></p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("does not touch relative links", () => {
|
||||
const input = `[Canvas](../../Canvas.html)`;
|
||||
const expected = /*html*/`<p><a href="../../Canvas.html">Canvas</a></p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("imports back to reference links", () => {
|
||||
const input = `<a class="reference-link" href="../../Canvas.html">Canvas</a>`;
|
||||
const expected = /*html*/`<p><a class="reference-link" href="../../Canvas.html">Canvas</a></p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("preserves figures and images with sizes", () => {
|
||||
const scenarios = [
|
||||
/*html*/`<figure class="image image-style-align-center image_resized" style="width:53.44%;"><img style="aspect-ratio:991/403;" src="Jump to Note_image.png" width="991" height="403"></figure>`,
|
||||
/*html*/`<figure class="image image-style-align-center image_resized" style="width:53.44%;"><img style="aspect-ratio:991/403;" src="Jump to Note_image.png" width="991" height="403"></figure>`,
|
||||
/*html*/`<img class="image_resized" style="aspect-ratio:853/315;width:50%;" src="6_File_image.png" width="853" height="315">`
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
expect(markdownService.renderToHtml(scenario, "Title")).toStrictEqual(scenario);
|
||||
}
|
||||
});
|
||||
|
||||
it("converts inline math expressions into Mathtex format", () => {
|
||||
const input = `The equation is\u00a0$e=mc^{2}$.`;
|
||||
const expected = /*html*/`<p>The equation is <span class="math-tex">\\(e=mc^{2}\\)</span>.</p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("converts display math expressions into Mathtex format", () => {
|
||||
const input = `$$\sqrt{x^{2}+1}$$`;
|
||||
const expected = /*html*/`<p><span class="math-tex">\\[\sqrt{x^{2}+1}\\]</span></p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("preserves escaped math expressions", () => {
|
||||
const scenarios = [
|
||||
"\\$\\$\sqrt{x^{2}+1}\\$\\$",
|
||||
"The equation is \\$e=mc^{2}\\$."
|
||||
];
|
||||
for (const scenario of scenarios) {
|
||||
expect(markdownService.renderToHtml(scenario, "Title")).toStrictEqual(`<p>${scenario}</p>`);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves table with column widths", () => {
|
||||
const html = /*html*/`<figure class="table" style="width:100%;"><table class="ck-table-resized"><colgroup><col style="width:2.77%;"><col style="width:33.42%;"><col style="width:63.81%;"></colgroup><thead><tr><th> </th><th> </th><th> </th></tr></thead><tbody><tr><td>1</td><td><img class="image_resized" style="aspect-ratio:562/454;width:100%;" src="1_Geo Map_image.png" width="562" height="454"></td><td>Go to any location on openstreetmap.org and right click to bring up the context menu. Select the “Show address” item.</td></tr><tr><td>2</td><td><img class="image_resized" style="aspect-ratio:696/480;width:100%;" src="Geo Map_image.png" width="696" height="480"></td><td>The address will be visible in the top-left of the screen, in the place of the search bar. <br><br>Select the coordinates and copy them into the clipboard.</td></tr><tr><td>3</td><td><img class="image_resized" style="aspect-ratio:640/276;width:100%;" src="5_Geo Map_image.png" width="640" height="276"></td><td>Simply paste the value inside the text box into the <code>#geolocation</code> attribute of a child note of the map and then it should be displayed on the map.</td></tr></tbody></table></figure>`;
|
||||
expect(markdownService.renderToHtml(html, "Title")).toStrictEqual(html);
|
||||
});
|
||||
|
||||
it("generates strike-through text", () => {
|
||||
const input = `~~Hello~~ world.`;
|
||||
const expected = /*html*/`<p><del>Hello</del> world.</p>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("does not generate additional spacing when importing lists", () => {
|
||||
const input = trimIndentation`\
|
||||
### 🐞 Bugfixes
|
||||
|
||||
* [v0.90.4 docker does not read USER\_UID and USER\_GID from environment](https://github.com/TriliumNext/Notes/issues/331)
|
||||
* [Invalid CSRF token on Android phone](https://github.com/TriliumNext/Notes/issues/318)
|
||||
* [Excess spacing in lists](https://github.com/TriliumNext/Notes/issues/341)`;
|
||||
const expected = [
|
||||
/*html*/`<h3>🐞 Bugfixes</h3>`,
|
||||
/*html*/`<ul>`,
|
||||
/*html*/`<li><a href="https://github.com/TriliumNext/Notes/issues/331">v0.90.4 docker does not read USER_UID and USER_GID from environment</a></li>`,
|
||||
/*html*/`<li><a href="https://github.com/TriliumNext/Notes/issues/318">Invalid CSRF token on Android phone</a></li>`,
|
||||
/*html*/`<li><a href="https://github.com/TriliumNext/Notes/issues/341">Excess spacing in lists</a></li>`,
|
||||
/*html*/`</ul>`
|
||||
].join("");
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it("imports todo lists properly", () => {
|
||||
const input = trimIndentation`\
|
||||
- [x] Hello
|
||||
- [ ] World`;
|
||||
const expected = `<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>`;
|
||||
expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import { parse, Renderer, type Tokens } from "marked";
|
||||
|
||||
/**
|
||||
* Keep renderer code up to date with https://github.com/markedjs/marked/blob/master/src/Renderer.ts.
|
||||
*/
|
||||
class CustomMarkdownRenderer extends Renderer {
|
||||
|
||||
heading(data: Tokens.Heading): string {
|
||||
// Treat h1 as raw text.
|
||||
if (data.depth === 1) {
|
||||
return `<h1>${data.text}</h1>`;
|
||||
}
|
||||
|
||||
return super.heading(data).trimEnd();
|
||||
}
|
||||
|
||||
paragraph(data: Tokens.Paragraph): string {
|
||||
let text = super.paragraph(data).trimEnd();
|
||||
|
||||
if (text.includes("$")) {
|
||||
// Display math
|
||||
text = text.replaceAll(/(?<!\\)\$\$(.+)\$\$/g,
|
||||
`<span class="math-tex">\\\[$1\\\]</span>`);
|
||||
|
||||
// Inline math
|
||||
text = text.replaceAll(/(?<!\\)\$(.+)\$/g,
|
||||
`<span class="math-tex">\\\($1\\\)</span>`);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
code({ text, lang }: Tokens.Code): string {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Escape the HTML.
|
||||
text = utils.escapeHtml(text);
|
||||
|
||||
// Unescape "
|
||||
text = text.replace(/"/g, '"');
|
||||
|
||||
const ckEditorLanguage = getNormalizedMimeFromMarkdownLanguage(lang);
|
||||
return `<pre><code class="language-${ckEditorLanguage}">${text}</code></pre>`;
|
||||
}
|
||||
|
||||
list(token: Tokens.List): string {
|
||||
let result = super.list(token)
|
||||
.replace("\n", "") // we replace the first one only.
|
||||
.trimEnd();
|
||||
|
||||
// Handle todo-list in the CKEditor format.
|
||||
if (token.items.some(item => item.task)) {
|
||||
result = result.replace(/^<ul>/, "<ul class=\"todo-list\">");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
checkbox({ checked }: Tokens.Checkbox): string {
|
||||
return '<input type="checkbox"'
|
||||
+ (checked ? 'checked="checked" ' : '')
|
||||
+ 'disabled="disabled">';
|
||||
}
|
||||
|
||||
listitem(item: Tokens.ListItem): string {
|
||||
// Handle todo-list in the CKEditor format.
|
||||
if (item.task) {
|
||||
let itemBody = '';
|
||||
const checkbox = this.checkbox({ checked: !!item.checked });
|
||||
if (item.loose) {
|
||||
if (item.tokens[0]?.type === 'paragraph') {
|
||||
item.tokens[0].text = checkbox + item.tokens[0].text;
|
||||
if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') {
|
||||
item.tokens[0].tokens[0].text = checkbox + escape(item.tokens[0].tokens[0].text);
|
||||
item.tokens[0].tokens[0].escaped = true;
|
||||
}
|
||||
} else {
|
||||
item.tokens.unshift({
|
||||
type: 'text',
|
||||
raw: checkbox,
|
||||
text: checkbox,
|
||||
escaped: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
itemBody += checkbox;
|
||||
}
|
||||
|
||||
itemBody += `<span class="todo-list__label__description">${this.parser.parse(item.tokens, !!item.loose)}</span>`;
|
||||
return `<li><label class="todo-list__label">${itemBody}</label></li>`;
|
||||
}
|
||||
|
||||
return super.listitem(item).trimEnd();
|
||||
}
|
||||
|
||||
image(token: Tokens.Image): string {
|
||||
return super.image(token)
|
||||
.replace(` alt=""`, "");
|
||||
}
|
||||
|
||||
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||
const body = renderer.parser.parse(tokens);
|
||||
|
||||
const admonitionMatch = /^<p>\[\!([A-Z]+)\]/.exec(body);
|
||||
if (Array.isArray(admonitionMatch) && admonitionMatch.length === 2) {
|
||||
const type = admonitionMatch[1].toLowerCase();
|
||||
|
||||
if (ADMONITION_TYPE_MAPPINGS[type]) {
|
||||
const bodyWithoutHeader = body
|
||||
.replace(/^<p>\[\!([A-Z]+)\]\s*/, "<p>")
|
||||
.replace(/^<p><\/p>/, ""); // Having a heading will generate an empty paragraph that we need to remove.
|
||||
|
||||
return `<aside class="admonition ${type}">${bodyWithoutHeader.trim()}</aside>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<blockquote>${body}</blockquote>`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const renderer = new CustomMarkdownRenderer({ async: false });
|
||||
|
||||
import htmlSanitizer from "../html_sanitizer.js";
|
||||
import importUtils from "./utils.js";
|
||||
import { getMimeTypeFromHighlightJs, MIME_TYPE_AUTO, normalizeMimeTypeForCKEditor } from "./mime_type_definitions.js";
|
||||
import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js";
|
||||
import utils from "../utils.js";
|
||||
|
||||
function renderToHtml(content: string, title: string) {
|
||||
// Double-escape slashes in math expression because they are otherwise consumed by the parser somewhere.
|
||||
content = content.replaceAll("\\$", "\\\\$");
|
||||
|
||||
let html = parse(content, {
|
||||
async: false,
|
||||
renderer: renderer
|
||||
}) as string;
|
||||
|
||||
// h1 handling needs to come before sanitization
|
||||
html = importUtils.handleH1(html, title);
|
||||
html = htmlSanitizer.sanitize(html);
|
||||
|
||||
// Add a trailing semicolon to CSS styles.
|
||||
html = html.replaceAll(/(<(img|figure|col).*?style=".*?)"/g, "$1;\"");
|
||||
|
||||
// Remove slash for self-closing tags to match CKEditor's approach.
|
||||
html = html.replace(/<(\w+)([^>]*)\s+\/>/g, "<$1$2>");
|
||||
|
||||
// Normalize non-breaking spaces to entity.
|
||||
html = html.replaceAll("\u00a0", " ");
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function getNormalizedMimeFromMarkdownLanguage(language: string | undefined) {
|
||||
if (language) {
|
||||
const highlightJsName = getMimeTypeFromHighlightJs(language);
|
||||
if (highlightJsName) {
|
||||
return normalizeMimeTypeForCKEditor(highlightJsName.mime);
|
||||
}
|
||||
}
|
||||
|
||||
return MIME_TYPE_AUTO;
|
||||
}
|
||||
|
||||
export default {
|
||||
renderToHtml
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import mimeService from "./mime.js";
|
||||
|
||||
type TestCase<T extends (...args: any) => any, W> = [desc: string, fnParams: Parameters<T>, expected: W];
|
||||
|
||||
describe("#getMime", () => {
|
||||
// prettier-ignore
|
||||
const testCases: TestCase<typeof mimeService.getMime, string | false>[] = [
|
||||
[
|
||||
"Dockerfile should be handled correctly",
|
||||
["Dockerfile"], "text/x-dockerfile"
|
||||
],
|
||||
|
||||
[
|
||||
"File extension ('.py') that is defined in EXTENSION_TO_MIME",
|
||||
["test.py"], "text/x-python"
|
||||
],
|
||||
|
||||
[
|
||||
"File extension ('.ts') that is defined in EXTENSION_TO_MIME",
|
||||
["test.ts"], "text/x-typescript"
|
||||
],
|
||||
|
||||
[
|
||||
"File extension ('.excalidraw') that is defined in EXTENSION_TO_MIME",
|
||||
["test.excalidraw"], "application/json"
|
||||
],
|
||||
|
||||
[
|
||||
"File extension ('.mermaid') that is defined in EXTENSION_TO_MIME",
|
||||
["test.mermaid"], "text/vnd.mermaid"
|
||||
],
|
||||
[
|
||||
"File extension ('.mermaid') that is defined in EXTENSION_TO_MIME",
|
||||
["test.mmd"], "text/vnd.mermaid"
|
||||
],
|
||||
|
||||
[
|
||||
"File extension with inconsistent capitalization that is defined in EXTENSION_TO_MIME",
|
||||
["test.gRoOvY"], "text/x-groovy"
|
||||
],
|
||||
|
||||
[
|
||||
"File extension that is not defined in EXTENSION_TO_MIME should use mimeTypes.lookup",
|
||||
["test.zip"], "application/zip"
|
||||
],
|
||||
|
||||
[
|
||||
"unknown MIME type not recognized by mimeTypes.lookup",
|
||||
["test.fake"], false
|
||||
],
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
const [testDesc, fnParams, expected] = testCase;
|
||||
it(`${testDesc}: '${fnParams} should return '${expected}'`, () => {
|
||||
const actual = mimeService.getMime(...fnParams);
|
||||
expect(actual, testDesc).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("#getType", () => {
|
||||
// prettier-ignore
|
||||
const testCases: TestCase<typeof mimeService.getType, string>[] = [
|
||||
[
|
||||
"w/ no import options set and mime type empty – it should return 'file'",
|
||||
[{}, ""], "file"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ no import options set and non-text or non-code mime type – it should return 'file'",
|
||||
[{}, "application/zip"], "file"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ import options set and an image mime type – it should return 'image'",
|
||||
[{}, "image/jpeg"], "image"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ image mime type and codeImportedAsCode: true – it should still return 'image'",
|
||||
[{codeImportedAsCode: true}, "image/jpeg"], "image"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ image mime type and textImportedAsText: true – it should still return 'image'",
|
||||
[{textImportedAsText: true}, "image/jpeg"], "image"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ codeImportedAsCode: true and a mime type that is in CODE_MIME_TYPES – it should return 'code'",
|
||||
[{codeImportedAsCode: true}, "text/css"], "code"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ codeImportedAsCode: false and a mime type that is in CODE_MIME_TYPES – it should return 'file' not 'code'",
|
||||
[{codeImportedAsCode: false}, "text/css"], "file"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ textImportedAsText: true and 'text/html' mime type – it should return 'text'",
|
||||
[{textImportedAsText: true}, "text/html"], "text"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ textImportedAsText: true and 'text/markdown' mime type – it should return 'text'",
|
||||
[{textImportedAsText: true}, "text/markdown"], "text"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ textImportedAsText: true and 'text/x-markdown' mime type – it should return 'text'",
|
||||
[{textImportedAsText: true}, "text/x-markdown"], "text"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ textImportedAsText: false and 'text/x-markdown' mime type – it should return 'file'",
|
||||
[{textImportedAsText: false}, "text/x-markdown"], "file"
|
||||
],
|
||||
|
||||
[
|
||||
"w/ textImportedAsText: false and 'text/html' mime type – it should return 'file'",
|
||||
[{textImportedAsText: false}, "text/html"], "file"
|
||||
],
|
||||
|
||||
]
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
const [desc, fnParams, expected] = testCase;
|
||||
it(desc, () => {
|
||||
const actual = mimeService.getType(...fnParams);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("#normalizeMimeType", () => {
|
||||
// prettier-ignore
|
||||
const testCases: TestCase<typeof mimeService.normalizeMimeType, string | undefined>[] = [
|
||||
|
||||
[
|
||||
"empty mime should return undefined",
|
||||
[""], undefined
|
||||
],
|
||||
[
|
||||
"a mime that's defined in CODE_MIME_TYPES should return the same mime",
|
||||
["text/x-python"], "text/x-python"
|
||||
],
|
||||
[
|
||||
"a mime (with capitalization inconsistencies) that's defined in CODE_MIME_TYPES should return the same mime in lowercase",
|
||||
["text/X-pYthOn"], "text/x-python"
|
||||
],
|
||||
[
|
||||
"a mime that's non defined in CODE_MIME_TYPES should return undefined",
|
||||
["application/zip"], undefined
|
||||
],
|
||||
[
|
||||
"a mime that's defined in CODE_MIME_TYPES with a 'rewrite rule' should return the rewritten mime",
|
||||
["text/markdown"], "text/x-markdown"
|
||||
]
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
const [desc, fnParams, expected] = testCase;
|
||||
it(desc, () => {
|
||||
const actual = mimeService.normalizeMimeType(...fnParams);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import mimeTypes from "mime-types";
|
||||
import path from "path";
|
||||
import type { TaskData } from "../task_context_interface.js";
|
||||
import type { NoteType } from "@triliumnext/commons";
|
||||
|
||||
const CODE_MIME_TYPES = new Set([
|
||||
"application/json",
|
||||
"message/http",
|
||||
"text/css",
|
||||
"text/html",
|
||||
"text/plain",
|
||||
"text/x-clojure",
|
||||
"text/x-csharp",
|
||||
"text/x-c++src",
|
||||
"text/x-csrc",
|
||||
"text/x-dockerfile",
|
||||
"text/x-erlang",
|
||||
"text/x-feature",
|
||||
"text/x-go",
|
||||
"text/x-groovy",
|
||||
"text/x-haskell",
|
||||
"text/x-java",
|
||||
"text/x-kotlin",
|
||||
"text/x-lua",
|
||||
"text/x-markdown",
|
||||
"text/xml",
|
||||
"text/x-objectivec",
|
||||
"text/x-pascal",
|
||||
"text/x-perl",
|
||||
"text/x-php",
|
||||
"text/x-python",
|
||||
"text/x-ruby",
|
||||
"text/x-rustsrc",
|
||||
"text/x-scala",
|
||||
"text/x-sh",
|
||||
"text/x-sql",
|
||||
"text/x-stex",
|
||||
"text/x-swift",
|
||||
"text/x-typescript",
|
||||
"text/x-yaml"
|
||||
]);
|
||||
|
||||
const CODE_MIME_TYPES_OVERRIDE = new Map<string, string>([
|
||||
["application/javascript", "application/javascript;env=frontend"],
|
||||
["application/x-javascript", "application/javascript;env=frontend"],
|
||||
// possibly later migrate to text/markdown as primary MIME
|
||||
["text/markdown", "text/x-markdown"]
|
||||
]);
|
||||
|
||||
// extensions missing in mime-db
|
||||
const EXTENSION_TO_MIME = new Map<string, string>([
|
||||
[".c", "text/x-csrc"],
|
||||
[".cs", "text/x-csharp"],
|
||||
[".clj", "text/x-clojure"],
|
||||
[".erl", "text/x-erlang"],
|
||||
[".hrl", "text/x-erlang"],
|
||||
[".feature", "text/x-feature"],
|
||||
[".go", "text/x-go"],
|
||||
[".groovy", "text/x-groovy"],
|
||||
[".hs", "text/x-haskell"],
|
||||
[".lhs", "text/x-haskell"],
|
||||
[".http", "message/http"],
|
||||
[".kt", "text/x-kotlin"],
|
||||
[".m", "text/x-objectivec"],
|
||||
[".py", "text/x-python"],
|
||||
[".rb", "text/x-ruby"],
|
||||
[".scala", "text/x-scala"],
|
||||
[".swift", "text/x-swift"],
|
||||
[".ts", "text/x-typescript"],
|
||||
[".excalidraw", "application/json"],
|
||||
[".mermaid", "text/vnd.mermaid"],
|
||||
[".mmd", "text/vnd.mermaid"]
|
||||
]);
|
||||
|
||||
/** @returns false if MIME is not detected */
|
||||
function getMime(fileName: string) {
|
||||
const fileNameLc = fileName?.toLowerCase();
|
||||
|
||||
if (fileNameLc === "dockerfile") {
|
||||
return "text/x-dockerfile";
|
||||
}
|
||||
|
||||
const ext = path.extname(fileNameLc);
|
||||
const mimeFromExt = EXTENSION_TO_MIME.get(ext);
|
||||
|
||||
return mimeFromExt || mimeTypes.lookup(fileNameLc);
|
||||
}
|
||||
|
||||
function getType(options: TaskData, mime: string): NoteType {
|
||||
const mimeLc = mime?.toLowerCase();
|
||||
|
||||
switch (true) {
|
||||
case options.textImportedAsText && ["text/html", "text/markdown", "text/x-markdown", "text/mdx"].includes(mimeLc):
|
||||
return "text";
|
||||
|
||||
case options.codeImportedAsCode && CODE_MIME_TYPES.has(mimeLc):
|
||||
return "code";
|
||||
|
||||
case mime.startsWith("image/"):
|
||||
return "image";
|
||||
|
||||
case mime === "text/vnd.mermaid":
|
||||
return "mermaid";
|
||||
|
||||
default:
|
||||
return "file";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMimeType(mime: string) {
|
||||
const mimeLc = mime.toLowerCase();
|
||||
|
||||
//prettier-ignore
|
||||
return CODE_MIME_TYPES.has(mimeLc)
|
||||
? mimeLc
|
||||
: CODE_MIME_TYPES_OVERRIDE.get(mimeLc);
|
||||
}
|
||||
|
||||
export default {
|
||||
getMime,
|
||||
getType,
|
||||
normalizeMimeType
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
// TODO: deduplicate with /src/public/app/services/mime_type_definitions.ts
|
||||
|
||||
/**
|
||||
* A pseudo-MIME type which is used in the editor to automatically determine the language used in code blocks via heuristics.
|
||||
*/
|
||||
export const MIME_TYPE_AUTO = "text-x-trilium-auto";
|
||||
|
||||
export interface MimeTypeDefinition {
|
||||
default?: boolean;
|
||||
title: string;
|
||||
mime: string;
|
||||
/** The name of the language/mime type as defined by highlight.js (or one of the aliases), in order to be used for syntax highlighting such as inside code blocks. */
|
||||
highlightJs?: string;
|
||||
/** If specified, will load the corresponding highlight.js file from the `libraries/highlightjs/${id}.js` instead of `node_modules/@highlightjs/cdn-assets/languages/${id}.min.js`. */
|
||||
highlightJsSource?: "libraries";
|
||||
/** If specified, will load the corresponding highlight file from the given path instead of `node_modules`. */
|
||||
codeMirrorSource?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* For highlight.js-supported languages, see https://github.com/highlightjs/highlight.js/blob/main/SUPPORTED_LANGUAGES.md.
|
||||
*/
|
||||
|
||||
export const MIME_TYPES_DICT: readonly MimeTypeDefinition[] = Object.freeze([
|
||||
{ title: "Plain text", mime: "text/plain", highlightJs: "plaintext", default: true },
|
||||
|
||||
// Keep sorted alphabetically.
|
||||
{ title: "APL", mime: "text/apl" },
|
||||
{ title: "ASN.1", mime: "text/x-ttcn-asn" },
|
||||
{ title: "ASP.NET", mime: "application/x-aspx" },
|
||||
{ title: "Asterisk", mime: "text/x-asterisk" },
|
||||
{ title: "Batch file (DOS)", mime: "application/x-bat", highlightJs: "dos", codeMirrorSource: "libraries/codemirror/batch.js" },
|
||||
{ title: "Brainfuck", mime: "text/x-brainfuck", highlightJs: "brainfuck" },
|
||||
{ title: "C", mime: "text/x-csrc", highlightJs: "c", default: true },
|
||||
{ title: "C#", mime: "text/x-csharp", highlightJs: "csharp", default: true },
|
||||
{ title: "C++", mime: "text/x-c++src", highlightJs: "cpp", default: true },
|
||||
{ title: "Clojure", mime: "text/x-clojure", highlightJs: "clojure" },
|
||||
{ title: "ClojureScript", mime: "text/x-clojurescript" },
|
||||
{ title: "Closure Stylesheets (GSS)", mime: "text/x-gss" },
|
||||
{ title: "CMake", mime: "text/x-cmake", highlightJs: "cmake" },
|
||||
{ title: "Cobol", mime: "text/x-cobol" },
|
||||
{ title: "CoffeeScript", mime: "text/coffeescript", highlightJs: "coffeescript" },
|
||||
{ title: "Common Lisp", mime: "text/x-common-lisp", highlightJs: "lisp" },
|
||||
{ title: "CQL", mime: "text/x-cassandra" },
|
||||
{ title: "Crystal", mime: "text/x-crystal", highlightJs: "crystal" },
|
||||
{ title: "CSS", mime: "text/css", highlightJs: "css", default: true },
|
||||
{ title: "Cypher", mime: "application/x-cypher-query" },
|
||||
{ title: "Cython", mime: "text/x-cython" },
|
||||
{ title: "D", mime: "text/x-d", highlightJs: "d" },
|
||||
{ title: "Dart", mime: "application/dart", highlightJs: "dart" },
|
||||
{ title: "diff", mime: "text/x-diff", highlightJs: "diff" },
|
||||
{ title: "Django", mime: "text/x-django", highlightJs: "django" },
|
||||
{ title: "Dockerfile", mime: "text/x-dockerfile", highlightJs: "dockerfile" },
|
||||
{ title: "DTD", mime: "application/xml-dtd" },
|
||||
{ title: "Dylan", mime: "text/x-dylan" },
|
||||
{ title: "EBNF", mime: "text/x-ebnf", highlightJs: "ebnf" },
|
||||
{ title: "ECL", mime: "text/x-ecl" },
|
||||
{ title: "edn", mime: "application/edn" },
|
||||
{ title: "Eiffel", mime: "text/x-eiffel" },
|
||||
{ title: "Elm", mime: "text/x-elm", highlightJs: "elm" },
|
||||
{ title: "Embedded Javascript", mime: "application/x-ejs" },
|
||||
{ title: "Embedded Ruby", mime: "application/x-erb", highlightJs: "erb" },
|
||||
{ title: "Erlang", mime: "text/x-erlang", highlightJs: "erlang" },
|
||||
{ title: "Esper", mime: "text/x-esper" },
|
||||
{ title: "F#", mime: "text/x-fsharp", highlightJs: "fsharp" },
|
||||
{ title: "Factor", mime: "text/x-factor" },
|
||||
{ title: "FCL", mime: "text/x-fcl" },
|
||||
{ title: "Forth", mime: "text/x-forth" },
|
||||
{ title: "Fortran", mime: "text/x-fortran", highlightJs: "fortran" },
|
||||
{ title: "Gas", mime: "text/x-gas" },
|
||||
{ title: "Gherkin", mime: "text/x-feature", highlightJs: "gherkin" },
|
||||
{ title: "GitHub Flavored Markdown", mime: "text/x-gfm", highlightJs: "markdown" },
|
||||
{ title: "Go", mime: "text/x-go", highlightJs: "go", default: true },
|
||||
{ title: "Groovy", mime: "text/x-groovy", highlightJs: "groovy", default: true },
|
||||
{ title: "HAML", mime: "text/x-haml", highlightJs: "haml" },
|
||||
{ title: "Haskell (Literate)", mime: "text/x-literate-haskell" },
|
||||
{ title: "Haskell", mime: "text/x-haskell", highlightJs: "haskell", default: true },
|
||||
{ title: "Haxe", mime: "text/x-haxe", highlightJs: "haxe" },
|
||||
{ title: "HTML", mime: "text/html", highlightJs: "xml", default: true },
|
||||
{ title: "HTTP", mime: "message/http", highlightJs: "http", default: true },
|
||||
{ title: "HXML", mime: "text/x-hxml" },
|
||||
{ title: "IDL", mime: "text/x-idl" },
|
||||
{ title: "Java Server Pages", mime: "application/x-jsp", highlightJs: "java" },
|
||||
{ title: "Java", mime: "text/x-java", highlightJs: "java", default: true },
|
||||
{ title: "Jinja2", mime: "text/jinja2" },
|
||||
{ title: "JS backend", mime: "application/javascript;env=backend", highlightJs: "javascript", default: true },
|
||||
{ title: "JS frontend", mime: "application/javascript;env=frontend", highlightJs: "javascript", default: true },
|
||||
{ title: "JSON-LD", mime: "application/ld+json", highlightJs: "json" },
|
||||
{ title: "JSON", mime: "application/json", highlightJs: "json", default: true },
|
||||
{ title: "JSX", mime: "text/jsx", highlightJs: "javascript" },
|
||||
{ title: "Julia", mime: "text/x-julia", highlightJs: "julia" },
|
||||
{ title: "Kotlin", mime: "text/x-kotlin", highlightJs: "kotlin", default: true },
|
||||
{ title: "LaTeX", mime: "text/x-latex", highlightJs: "latex" },
|
||||
{ title: "LESS", mime: "text/x-less", highlightJs: "less" },
|
||||
{ title: "LiveScript", mime: "text/x-livescript", highlightJs: "livescript" },
|
||||
{ title: "Lua", mime: "text/x-lua", highlightJs: "lua" },
|
||||
{ title: "MariaDB SQL", mime: "text/x-mariadb", highlightJs: "sql" },
|
||||
{ title: "Markdown", mime: "text/x-markdown", highlightJs: "markdown", default: true },
|
||||
{ title: "Mathematica", mime: "text/x-mathematica", highlightJs: "mathematica" },
|
||||
{ title: "mbox", mime: "application/mbox" },
|
||||
{ title: "MIPS Assembler", mime: "text/x-asm-mips", highlightJs: "mips" },
|
||||
{ title: "mIRC", mime: "text/mirc" },
|
||||
{ title: "Modelica", mime: "text/x-modelica" },
|
||||
{ title: "MS SQL", mime: "text/x-mssql", highlightJs: "sql" },
|
||||
{ title: "mscgen", mime: "text/x-mscgen" },
|
||||
{ title: "msgenny", mime: "text/x-msgenny" },
|
||||
{ title: "MUMPS", mime: "text/x-mumps" },
|
||||
{ title: "MySQL", mime: "text/x-mysql", highlightJs: "sql" },
|
||||
{ title: "Nginx", mime: "text/x-nginx-conf", highlightJs: "nginx" },
|
||||
{ title: "NSIS", mime: "text/x-nsis", highlightJs: "nsis" },
|
||||
{ title: "NTriples", mime: "application/n-triples" },
|
||||
{ title: "Objective-C", mime: "text/x-objectivec", highlightJs: "objectivec" },
|
||||
{ title: "OCaml", mime: "text/x-ocaml", highlightJs: "ocaml" },
|
||||
{ title: "Octave", mime: "text/x-octave" },
|
||||
{ title: "Oz", mime: "text/x-oz" },
|
||||
{ title: "Pascal", mime: "text/x-pascal", highlightJs: "delphi" },
|
||||
{ title: "PEG.js", mime: "null" },
|
||||
{ title: "Perl", mime: "text/x-perl", default: true },
|
||||
{ title: "PGP", mime: "application/pgp" },
|
||||
{ title: "PHP", mime: "text/x-php", default: true },
|
||||
{ title: "Pig", mime: "text/x-pig" },
|
||||
{ title: "PLSQL", mime: "text/x-plsql", highlightJs: "sql" },
|
||||
{ title: "PostgreSQL", mime: "text/x-pgsql", highlightJs: "pgsql" },
|
||||
{ title: "PowerShell", mime: "application/x-powershell", highlightJs: "powershell" },
|
||||
{ title: "Properties files", mime: "text/x-properties", highlightJs: "properties" },
|
||||
{ title: "ProtoBuf", mime: "text/x-protobuf", highlightJs: "protobuf" },
|
||||
{ title: "Pug", mime: "text/x-pug" },
|
||||
{ title: "Puppet", mime: "text/x-puppet", highlightJs: "puppet" },
|
||||
{ title: "Python", mime: "text/x-python", highlightJs: "python", default: true },
|
||||
{ title: "Q", mime: "text/x-q", highlightJs: "q" },
|
||||
{ title: "R", mime: "text/x-rsrc", highlightJs: "r" },
|
||||
{ title: "reStructuredText", mime: "text/x-rst" },
|
||||
{ title: "RPM Changes", mime: "text/x-rpm-changes" },
|
||||
{ title: "RPM Spec", mime: "text/x-rpm-spec" },
|
||||
{ title: "Ruby", mime: "text/x-ruby", highlightJs: "ruby", default: true },
|
||||
{ title: "Rust", mime: "text/x-rustsrc", highlightJs: "rust" },
|
||||
{ title: "SAS", mime: "text/x-sas", highlightJs: "sas" },
|
||||
{ title: "Sass", mime: "text/x-sass" },
|
||||
{ title: "Scala", mime: "text/x-scala" },
|
||||
{ title: "Scheme", mime: "text/x-scheme" },
|
||||
{ title: "SCSS", mime: "text/x-scss", highlightJs: "scss" },
|
||||
{ title: "Shell (bash)", mime: "text/x-sh", highlightJs: "bash", default: true },
|
||||
{ title: "Sieve", mime: "application/sieve" },
|
||||
{ title: "Slim", mime: "text/x-slim" },
|
||||
{ title: "Smalltalk", mime: "text/x-stsrc", highlightJs: "smalltalk" },
|
||||
{ title: "Smarty", mime: "text/x-smarty" },
|
||||
{ title: "SML", mime: "text/x-sml", highlightJs: "sml" },
|
||||
{ title: "Solr", mime: "text/x-solr" },
|
||||
{ title: "Soy", mime: "text/x-soy" },
|
||||
{ title: "SPARQL", mime: "application/sparql-query" },
|
||||
{ title: "Spreadsheet", mime: "text/x-spreadsheet" },
|
||||
{ title: "SQL", mime: "text/x-sql", highlightJs: "sql", default: true },
|
||||
{ title: "SQLite (Trilium)", mime: "text/x-sqlite;schema=trilium", highlightJs: "sql", default: true },
|
||||
{ title: "SQLite", mime: "text/x-sqlite", highlightJs: "sql" },
|
||||
{ title: "Squirrel", mime: "text/x-squirrel" },
|
||||
{ title: "sTeX", mime: "text/x-stex" },
|
||||
{ title: "Stylus", mime: "text/x-styl", highlightJs: "stylus" },
|
||||
{ title: "Swift", mime: "text/x-swift", default: true },
|
||||
{ title: "SystemVerilog", mime: "text/x-systemverilog" },
|
||||
{ title: "Tcl", mime: "text/x-tcl", highlightJs: "tcl" },
|
||||
{ title: "Terraform (HCL)", mime: "text/x-hcl", highlightJs: "terraform", highlightJsSource: "libraries", codeMirrorSource: "libraries/codemirror/hcl.js" },
|
||||
{ title: "Textile", mime: "text/x-textile" },
|
||||
{ title: "TiddlyWiki ", mime: "text/x-tiddlywiki" },
|
||||
{ title: "Tiki wiki", mime: "text/tiki" },
|
||||
{ title: "TOML", mime: "text/x-toml", highlightJs: "ini" },
|
||||
{ title: "Tornado", mime: "text/x-tornado" },
|
||||
{ title: "troff", mime: "text/troff" },
|
||||
{ title: "TTCN_CFG", mime: "text/x-ttcn-cfg" },
|
||||
{ title: "TTCN", mime: "text/x-ttcn" },
|
||||
{ title: "Turtle", mime: "text/turtle" },
|
||||
{ title: "Twig", mime: "text/x-twig", highlightJs: "twig" },
|
||||
{ title: "TypeScript-JSX", mime: "text/typescript-jsx" },
|
||||
{ title: "TypeScript", mime: "application/typescript", highlightJs: "typescript" },
|
||||
{ title: "VB.NET", mime: "text/x-vb", highlightJs: "vbnet" },
|
||||
{ title: "VBScript", mime: "text/vbscript", highlightJs: "vbscript" },
|
||||
{ title: "Velocity", mime: "text/velocity" },
|
||||
{ title: "Verilog", mime: "text/x-verilog", highlightJs: "verilog" },
|
||||
{ title: "VHDL", mime: "text/x-vhdl", highlightJs: "vhdl" },
|
||||
{ title: "Vue.js Component", mime: "text/x-vue" },
|
||||
{ title: "Web IDL", mime: "text/x-webidl" },
|
||||
{ title: "XML", mime: "text/xml", highlightJs: "xml", default: true },
|
||||
{ title: "XQuery", mime: "application/xquery", highlightJs: "xquery" },
|
||||
{ title: "xu", mime: "text/x-xu" },
|
||||
{ title: "Yacas", mime: "text/x-yacas" },
|
||||
{ title: "YAML", mime: "text/x-yaml", highlightJs: "yaml", default: true },
|
||||
{ title: "Z80", mime: "text/x-z80" }
|
||||
]);
|
||||
|
||||
/**
|
||||
* Given a MIME type in the usual format (e.g. `text/csrc`), it returns a MIME type that can be passed down to the CKEditor
|
||||
* code plugin.
|
||||
*
|
||||
* @param mimeType The MIME type to normalize, in the usual format (e.g. `text/c-src`).
|
||||
* @returns the normalized MIME type (e.g. `text-c-src`).
|
||||
*/
|
||||
export function normalizeMimeTypeForCKEditor(mimeType: string) {
|
||||
return mimeType.toLowerCase().replace(/[\W_]+/g, "-");
|
||||
}
|
||||
|
||||
let byHighlightJsNameMappings: Record<string, MimeTypeDefinition> | null = null;
|
||||
|
||||
/**
|
||||
* Given a Highlight.js language tag (e.g. `css`), it returns a corresponding {@link MimeTypeDefinition} if found.
|
||||
*
|
||||
* If there are multiple {@link MimeTypeDefinition}s for the language tag, then only the first one is retrieved. For example for `javascript`, the "JS frontend" mime type is returned.
|
||||
*
|
||||
* @param highlightJsName a language tag.
|
||||
* @returns the corresponding {@link MimeTypeDefinition} if found, or `undefined` otherwise.
|
||||
*/
|
||||
export function getMimeTypeFromHighlightJs(highlightJsName: string) {
|
||||
if (!byHighlightJsNameMappings) {
|
||||
byHighlightJsNameMappings = {};
|
||||
for (const mimeType of MIME_TYPES_DICT) {
|
||||
if (mimeType.highlightJs && !byHighlightJsNameMappings[mimeType.highlightJs]) {
|
||||
byHighlightJsNameMappings[mimeType.highlightJs] = mimeType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return byHighlightJsNameMappings[highlightJsName];
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import noteService from "../../services/notes.js";
|
||||
import xml2js from "xml2js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import htmlSanitizer from "../html_sanitizer.js";
|
||||
import type TaskContext from "../task_context.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
const parseString = xml2js.parseString;
|
||||
|
||||
interface OpmlXml {
|
||||
opml: OpmlBody;
|
||||
}
|
||||
|
||||
interface OpmlBody {
|
||||
$: {
|
||||
version: string;
|
||||
};
|
||||
body: OpmlOutline[];
|
||||
}
|
||||
|
||||
interface OpmlOutline {
|
||||
$: {
|
||||
title: string;
|
||||
text: string;
|
||||
_note: string;
|
||||
};
|
||||
outline: OpmlOutline[];
|
||||
}
|
||||
|
||||
async function importOpml(taskContext: TaskContext, fileBuffer: string | Buffer, parentNote: BNote) {
|
||||
const xml = await new Promise<OpmlXml>(function (resolve, reject) {
|
||||
parseString(fileBuffer, function (err: any, result: OpmlXml) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!["1.0", "1.1", "2.0"].includes(xml.opml.$.version)) {
|
||||
return [400, `Unsupported OPML version ${xml.opml.$.version}, 1.0, 1.1 or 2.0 expected instead.`];
|
||||
}
|
||||
|
||||
const opmlVersion = parseInt(xml.opml.$.version);
|
||||
|
||||
function importOutline(outline: OpmlOutline, parentNoteId: string) {
|
||||
let title, content;
|
||||
|
||||
if (opmlVersion === 1) {
|
||||
title = outline.$.title;
|
||||
content = toHtml(outline.$.text);
|
||||
|
||||
if (!title || !title.trim()) {
|
||||
// https://github.com/zadam/trilium/issues/1862
|
||||
title = outline.$.text;
|
||||
content = "";
|
||||
}
|
||||
} else if (opmlVersion === 2) {
|
||||
title = outline.$.text;
|
||||
content = outline.$._note; // _note is already HTML
|
||||
} else {
|
||||
throw new Error(`Unrecognized OPML version ${opmlVersion}`);
|
||||
}
|
||||
|
||||
content = htmlSanitizer.sanitize(content || "");
|
||||
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId,
|
||||
title,
|
||||
content,
|
||||
type: "text",
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
});
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
for (const childOutline of outline.outline || []) {
|
||||
importOutline(childOutline, note.noteId);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
const outlines = xml.opml.body[0].outline || [];
|
||||
let returnNote = null;
|
||||
|
||||
for (const outline of outlines) {
|
||||
const note = importOutline(outline, parentNote.noteId);
|
||||
|
||||
// the first created note will be activated after import
|
||||
returnNote = returnNote || note;
|
||||
}
|
||||
|
||||
return returnNote;
|
||||
}
|
||||
|
||||
function toHtml(text: string) {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `<p>${text.replace(/(?:\r\n|\r|\n)/g, "</p><p>")}</p>`;
|
||||
}
|
||||
|
||||
export default {
|
||||
importOpml
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"type":"excalidraw","version":2,"elements":[],"files":{},"appState":{"scrollX":0,"scrollY":0,"zoom":{"value":1}}}
|
||||
@@ -1,5 +0,0 @@
|
||||
graph TD;
|
||||
A-->B;
|
||||
A-->C;
|
||||
B-->D;
|
||||
C-->D;
|
||||
@@ -1,5 +0,0 @@
|
||||
graph TD;
|
||||
A-->B;
|
||||
A-->C;
|
||||
B-->D;
|
||||
C-->D;
|
||||
@@ -1,21 +0,0 @@
|
||||
Page 1
|
||||
|
||||
Heading 1
|
||||
---------
|
||||
|
||||
Heading 2
|
||||
---------
|
||||
|
||||
### Heading 3
|
||||
|
||||
```
|
||||
class Foo {
|
||||
hoistedNoteChangedEvent({ ntxId }) {
|
||||
if (this.isNoteContext(ntxId)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Page 2
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,117 +0,0 @@
|
||||
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";
|
||||
import BNote from "../../becca/entities/bnote.js";
|
||||
import TaskContext from "../task_context.js";
|
||||
import cls from "../cls.js";
|
||||
import sql_init from "../sql_init.js";
|
||||
import { initializeTranslations } from "../i18n.js";
|
||||
import single from "./single.js";
|
||||
import stripBom from "strip-bom";
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function testImport(fileName: string, mimetype: string) {
|
||||
const buffer = fs.readFileSync(path.join(scriptDir, "samples", fileName));
|
||||
const taskContext = TaskContext.getInstance("import-mdx", "import", {
|
||||
textImportedAsText: true,
|
||||
codeImportedAsCode: true
|
||||
});
|
||||
|
||||
return new Promise<{ buffer: Buffer; importedNote: BNote }>((resolve, reject) => {
|
||||
cls.init(async () => {
|
||||
const rootNote = becca.getNote("root");
|
||||
if (!rootNote) {
|
||||
reject("Missing root note.");
|
||||
}
|
||||
|
||||
const importedNote = single.importSingleFile(
|
||||
taskContext,
|
||||
{
|
||||
originalname: fileName,
|
||||
mimetype,
|
||||
buffer: buffer
|
||||
},
|
||||
rootNote as BNote
|
||||
);
|
||||
resolve({
|
||||
buffer,
|
||||
importedNote
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("processNoteContent", () => {
|
||||
beforeAll(async () => {
|
||||
// Prevent download of images.
|
||||
vi.mock("../image.js", () => {
|
||||
return {
|
||||
default: { saveImageToAttachment: () => {} }
|
||||
};
|
||||
});
|
||||
|
||||
initializeTranslations();
|
||||
sql_init.initializeDb();
|
||||
await sql_init.dbReady;
|
||||
});
|
||||
|
||||
it("treats single MDX as Markdown", async () => {
|
||||
const { importedNote } = await testImport("Text Note.mdx", "text/mdx");
|
||||
expect(importedNote.mime).toBe("text/html");
|
||||
expect(importedNote.type).toBe("text");
|
||||
expect(importedNote.title).toBe("Text Note");
|
||||
});
|
||||
|
||||
it("supports HTML note with UTF-16 (w/ BOM) from Microsoft Outlook", async () => {
|
||||
const { importedNote } = await testImport("IREN Reports Q2 FY25 Results.htm", "text/html");
|
||||
expect(importedNote.mime).toBe("text/html");
|
||||
expect(importedNote.title).toBe("IREN Reports Q2 FY25 Results");
|
||||
expect(importedNote.getContent().toString().substring(0, 5)).toEqual("<html");
|
||||
});
|
||||
|
||||
it("supports code note with UTF-16", async () => {
|
||||
const { importedNote, buffer } = await testImport("UTF-16LE Code Note.json", "application/json");
|
||||
expect(importedNote.mime).toBe("application/json");
|
||||
expect(importedNote.getContent().toString()).toStrictEqual(stripBom(buffer.toString("utf-16le")));
|
||||
});
|
||||
|
||||
it("supports plain text note with UTF-16", async () => {
|
||||
const { importedNote } = await testImport("UTF-16LE Text Note.txt", "text/plain");
|
||||
expect(importedNote.mime).toBe("text/html");
|
||||
expect(importedNote.getContent().toString()).toBe("<p>Plain text goes here.<br></p>");
|
||||
});
|
||||
|
||||
it("supports markdown note with UTF-16", async () => {
|
||||
const { importedNote } = await testImport("UTF-16LE Text Note.md", "text/markdown");
|
||||
expect(importedNote.mime).toBe("text/html");
|
||||
expect(importedNote.getContent().toString()).toBe("<h2>Hello world</h2><p>Plain text goes here.</p>");
|
||||
});
|
||||
|
||||
it("supports excalidraw note", async () => {
|
||||
const { importedNote } = await testImport("New note.excalidraw", "application/json");
|
||||
expect(importedNote.mime).toBe("application/json");
|
||||
expect(importedNote.type).toBe("canvas");
|
||||
expect(importedNote.title).toBe("New note");
|
||||
});
|
||||
|
||||
it("imports .mermaid as mermaid note", async () => {
|
||||
const { importedNote } = await testImport("New note.mermaid", "application/json");
|
||||
expect(importedNote).toMatchObject({
|
||||
mime: "text/vnd.mermaid",
|
||||
type: "mermaid",
|
||||
title: "New note"
|
||||
});
|
||||
});
|
||||
|
||||
it("imports .mmd as mermaid note", async () => {
|
||||
const { importedNote } = await testImport("New note.mmd", "application/json");
|
||||
expect(importedNote).toMatchObject({
|
||||
mime: "text/vnd.mermaid",
|
||||
type: "mermaid",
|
||||
title: "New note"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type TaskContext from "../task_context.js";
|
||||
|
||||
import noteService from "../../services/notes.js";
|
||||
import imageService from "../../services/image.js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import markdownService from "./markdown.js";
|
||||
import mimeService from "./mime.js";
|
||||
import { getNoteTitle, processStringOrBuffer } from "../../services/utils.js";
|
||||
import importUtils from "./utils.js";
|
||||
import htmlSanitizer from "../html_sanitizer.js";
|
||||
import type { File } from "./common.js";
|
||||
import type { NoteType } from "@triliumnext/commons";
|
||||
|
||||
function importSingleFile(taskContext: TaskContext, file: File, parentNote: BNote) {
|
||||
const mime = mimeService.getMime(file.originalname) || file.mimetype;
|
||||
|
||||
if (taskContext?.data?.textImportedAsText) {
|
||||
if (mime === "text/html") {
|
||||
return importHtml(taskContext, file, parentNote);
|
||||
} else if (["text/markdown", "text/x-markdown", "text/mdx"].includes(mime)) {
|
||||
return importMarkdown(taskContext, file, parentNote);
|
||||
} else if (mime === "text/plain") {
|
||||
return importPlainText(taskContext, file, parentNote);
|
||||
}
|
||||
}
|
||||
|
||||
if (mime === "text/vnd.mermaid") {
|
||||
return importCustomType(taskContext, file, parentNote, "mermaid", mime);
|
||||
}
|
||||
|
||||
if (taskContext?.data?.codeImportedAsCode && mimeService.getType(taskContext.data, mime) === "code") {
|
||||
return importCodeNote(taskContext, file, parentNote);
|
||||
}
|
||||
|
||||
if (mime.startsWith("image/")) {
|
||||
return importImage(file, parentNote, taskContext);
|
||||
}
|
||||
|
||||
return importFile(taskContext, file, parentNote);
|
||||
}
|
||||
|
||||
function importImage(file: File, parentNote: BNote, taskContext: TaskContext) {
|
||||
if (typeof file.buffer === "string") {
|
||||
throw new Error("Invalid file content for image.");
|
||||
}
|
||||
const { note } = imageService.saveImage(parentNote.noteId, file.buffer, file.originalname, !!taskContext.data?.shrinkImages);
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function importFile(taskContext: TaskContext, file: File, parentNote: BNote) {
|
||||
const originalName = file.originalname;
|
||||
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: parentNote.noteId,
|
||||
title: originalName,
|
||||
content: file.buffer,
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable(),
|
||||
type: "file",
|
||||
mime: mimeService.getMime(originalName) || file.mimetype
|
||||
});
|
||||
|
||||
note.addLabel("originalFileName", originalName);
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function importCodeNote(taskContext: TaskContext, file: File, parentNote: BNote) {
|
||||
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
|
||||
const content = processStringOrBuffer(file.buffer);
|
||||
const detectedMime = mimeService.getMime(file.originalname) || file.mimetype;
|
||||
const mime = mimeService.normalizeMimeType(detectedMime);
|
||||
|
||||
let type: NoteType = "code";
|
||||
if (file.originalname.endsWith(".excalidraw")) {
|
||||
type = "canvas";
|
||||
}
|
||||
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: parentNote.noteId,
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
mime: mime,
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
});
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function importCustomType(taskContext: TaskContext, file: File, parentNote: BNote, type: NoteType, mime: string) {
|
||||
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
|
||||
const content = processStringOrBuffer(file.buffer);
|
||||
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: parentNote.noteId,
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
mime: mime,
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
});
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function importPlainText(taskContext: TaskContext, file: File, parentNote: BNote) {
|
||||
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
|
||||
const plainTextContent = processStringOrBuffer(file.buffer);
|
||||
const htmlContent = convertTextToHtml(plainTextContent);
|
||||
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: parentNote.noteId,
|
||||
title,
|
||||
content: htmlContent,
|
||||
type: "text",
|
||||
mime: "text/html",
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
});
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function convertTextToHtml(text: string) {
|
||||
// 1: Plain Text Search
|
||||
text = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// 2: Line Breaks
|
||||
text = text.replace(/\r\n?|\n/g, "<br>");
|
||||
|
||||
// 3: Paragraphs
|
||||
text = text.replace(/<br>\s*<br>/g, "</p><p>");
|
||||
|
||||
// 4: Wrap in Paragraph Tags
|
||||
text = `<p>${text}</p>`;
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function importMarkdown(taskContext: TaskContext, file: File, parentNote: BNote) {
|
||||
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
|
||||
|
||||
const markdownContent = processStringOrBuffer(file.buffer);
|
||||
let htmlContent = markdownService.renderToHtml(markdownContent, title);
|
||||
|
||||
if (taskContext.data?.safeImport) {
|
||||
htmlContent = htmlSanitizer.sanitize(htmlContent);
|
||||
}
|
||||
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: parentNote.noteId,
|
||||
title,
|
||||
content: htmlContent,
|
||||
type: "text",
|
||||
mime: "text/html",
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
});
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function importHtml(taskContext: TaskContext, file: File, parentNote: BNote) {
|
||||
let content = processStringOrBuffer(file.buffer);
|
||||
|
||||
// Try to get title from HTML first, fall back to filename
|
||||
// We do this before sanitization since that turns all <h1>s into <h2>
|
||||
const htmlTitle = importUtils.extractHtmlTitle(content);
|
||||
const title = htmlTitle || getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
|
||||
|
||||
content = importUtils.handleH1(content, title);
|
||||
|
||||
if (taskContext?.data?.safeImport) {
|
||||
content = htmlSanitizer.sanitize(content);
|
||||
}
|
||||
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: parentNote.noteId,
|
||||
title,
|
||||
content,
|
||||
type: "text",
|
||||
mime: "text/html",
|
||||
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
});
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function importAttachment(taskContext: TaskContext, file: File, parentNote: BNote) {
|
||||
const mime = mimeService.getMime(file.originalname) || file.mimetype;
|
||||
|
||||
if (mime.startsWith("image/") && typeof file.buffer !== "string") {
|
||||
imageService.saveImageToAttachment(parentNote.noteId, file.buffer, file.originalname, taskContext.data?.shrinkImages);
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
} else {
|
||||
parentNote.saveAttachment({
|
||||
title: file.originalname,
|
||||
content: file.buffer,
|
||||
role: "file",
|
||||
mime: mime
|
||||
});
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
importSingleFile,
|
||||
importAttachment
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import importUtils from "./utils.js";
|
||||
|
||||
type TestCase<T extends (...args: any) => any> = [desc: string, fnParams: Parameters<T>, expected: ReturnType<T>];
|
||||
|
||||
describe("#extractHtmlTitle", () => {
|
||||
const htmlWithNoTitle = `
|
||||
<html>
|
||||
<body>
|
||||
<div>abc</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const htmlWithTitle = `
|
||||
<html><head>
|
||||
<title>Test Title</title>
|
||||
</head>
|
||||
<body>
|
||||
<div>abc</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const htmlWithTitleWOpeningBracket = `
|
||||
<html><head>
|
||||
<title>Test < Title</title>
|
||||
</head>
|
||||
<body>
|
||||
<div>abc</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// prettier-ignore
|
||||
const testCases: TestCase<typeof importUtils.extractHtmlTitle>[] = [
|
||||
[
|
||||
"w/ existing <title> tag, it should return the content of the title tag",
|
||||
[htmlWithTitle],
|
||||
"Test Title"
|
||||
],
|
||||
[
|
||||
// @TriliumNextTODO: this seems more like an unwanted behaviour to me – check if this needs rather fixing
|
||||
"with existing <title> tag, that includes an opening HTML tag '<', it should return null",
|
||||
[htmlWithTitleWOpeningBracket],
|
||||
null
|
||||
],
|
||||
[
|
||||
"w/o an existing <title> tag, it should reutrn null",
|
||||
[htmlWithNoTitle],
|
||||
null
|
||||
],
|
||||
[
|
||||
"w/ empty string content, it should return null",
|
||||
[""],
|
||||
null
|
||||
]
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
const [desc, fnParams, expected] = testCase;
|
||||
return it(desc, () => {
|
||||
const actual = importUtils.extractHtmlTitle(...fnParams);
|
||||
expect(actual).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("#handleH1", () => {
|
||||
// prettier-ignore
|
||||
const testCases: TestCase<typeof importUtils.handleH1>[] = [
|
||||
[
|
||||
"w/ single <h1> tag w/ identical text content as the title tag: the <h1> tag should be stripped",
|
||||
["<h1>Title</h1>", "Title"],
|
||||
""
|
||||
],
|
||||
[
|
||||
"w/ multiple <h1> tags, with the fist matching the title tag: the first <h1> tag should be stripped and subsequent tags converted to <h2>",
|
||||
["<h1>Title</h1><h1>Header 1</h1><h1>Header 2</h1>", "Title"],
|
||||
"<h2>Header 1</h2><h2>Header 2</h2>"
|
||||
],
|
||||
[
|
||||
"w/ no <h1> tag and only <h2> tags, it should not cause any changes and return the same content",
|
||||
["<h2>Heading 1</h2><h2>Heading 2</h2>", "Title"],
|
||||
"<h2>Heading 1</h2><h2>Heading 2</h2>"
|
||||
],
|
||||
[
|
||||
"w/ multiple <h1> tags, and the 1st matching the title tag, it should strip ONLY the very first occurence of the <h1> tags in the returned content",
|
||||
["<h1>Topic ABC</h1><h1>Heading 1</h1><h1>Topic ABC</h1>", "Topic ABC"],
|
||||
"<h2>Heading 1</h2><h2>Topic ABC</h2>"
|
||||
],
|
||||
[
|
||||
"w/ multiple <h1> tags, and the 1st matching NOT the title tag, it should NOT strip any other <h1> tags",
|
||||
["<h1>Introduction</h1><h1>Topic ABC</h1><h1>Summary</h1>", "Topic ABC"],
|
||||
"<h2>Introduction</h2><h2>Topic ABC</h2><h2>Summary</h2>"
|
||||
]
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
const [desc, fnParams, expected] = testCase;
|
||||
return it(desc, () => {
|
||||
const actual = importUtils.handleH1(...fnParams);
|
||||
expect(actual).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import { unescapeHtml } from "../utils.js";
|
||||
|
||||
function handleH1(content: string, title: string) {
|
||||
let isFirstH1Handled = false;
|
||||
|
||||
return content.replace(/<h1[^>]*>([^<]*)<\/h1>/gi, (match, text) => {
|
||||
text = unescapeHtml(text);
|
||||
const convertedContent = `<h2>${text}</h2>`;
|
||||
|
||||
// strip away very first found h1 tag, if it matches the title
|
||||
if (!isFirstH1Handled) {
|
||||
isFirstH1Handled = true;
|
||||
return title.trim() === text.trim() ? "" : convertedContent;
|
||||
}
|
||||
|
||||
return convertedContent;
|
||||
});
|
||||
}
|
||||
|
||||
function extractHtmlTitle(content: string): string | null {
|
||||
const titleMatch = content.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
return titleMatch ? titleMatch[1].trim() : null;
|
||||
}
|
||||
|
||||
export default {
|
||||
handleH1,
|
||||
extractHtmlTitle
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
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";
|
||||
import becca from "../../becca/becca.js";
|
||||
import BNote from "../../becca/entities/bnote.js";
|
||||
import TaskContext from "../task_context.js";
|
||||
import cls from "../cls.js";
|
||||
import sql_init from "../sql_init.js";
|
||||
import { initializeTranslations } from "../i18n.js";
|
||||
import { trimIndentation } from "@triliumnext/commons";
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function testImport(fileName: string) {
|
||||
const mdxSample = fs.readFileSync(path.join(scriptDir, "samples", fileName));
|
||||
const taskContext = TaskContext.getInstance("import-mdx", "import", {
|
||||
textImportedAsText: true
|
||||
});
|
||||
|
||||
return new Promise<{ importedNote: BNote; rootNote: BNote }>((resolve, reject) => {
|
||||
cls.init(async () => {
|
||||
const rootNote = becca.getNote("root");
|
||||
if (!rootNote) {
|
||||
expect(rootNote).toBeTruthy();
|
||||
return;
|
||||
}
|
||||
|
||||
const importedNote = await zip.importZip(taskContext, mdxSample, rootNote as BNote);
|
||||
resolve({
|
||||
importedNote,
|
||||
rootNote
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("processNoteContent", () => {
|
||||
beforeAll(async () => {
|
||||
// Prevent download of images.
|
||||
vi.mock("../image.js", () => {
|
||||
return {
|
||||
default: { saveImageToAttachment: () => {} }
|
||||
};
|
||||
});
|
||||
|
||||
initializeTranslations();
|
||||
sql_init.initializeDb();
|
||||
await sql_init.dbReady;
|
||||
});
|
||||
|
||||
it("treats single MDX as Markdown in ZIP as text note", async () => {
|
||||
const { importedNote } = await testImport("mdx.zip");
|
||||
expect(importedNote.mime).toBe("text/mdx");
|
||||
expect(importedNote.type).toBe("text");
|
||||
expect(importedNote.title).toBe("Text Note");
|
||||
});
|
||||
|
||||
it("can import email from Microsoft Outlook with UTF-16 with BOM", async () => {
|
||||
const { rootNote, importedNote } = await testImport("IREN.Reports.Q2.FY25.Results_files.zip");
|
||||
const htmlNote = rootNote.children.find((ch) => ch.title === "IREN Reports Q2 FY25 Results");
|
||||
expect(htmlNote?.getContent().toString().substring(0, 4)).toEqual("<div");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeTriliumTags", () => {
|
||||
it("removes <h1> tags from HTML", () => {
|
||||
const output = removeTriliumTags(trimIndentation`\
|
||||
<h1 data-trilium-h1>21 - Thursday</h1>
|
||||
<p>Hello world</p>
|
||||
`);
|
||||
const expected = `\n<p>Hello world</p>\n`;
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it("removes <title> tags from HTML", () => {
|
||||
const output = removeTriliumTags(trimIndentation`\
|
||||
<title data-trilium-title>21 - Thursday</title>
|
||||
<p>Hello world</p>
|
||||
`);
|
||||
const expected = `\n<p>Hello world</p>\n`;
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it("removes ckeditor tags from HTML", () => {
|
||||
const output = removeTriliumTags(trimIndentation`\
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1 data-trilium-h1>21 - Thursday</h1>
|
||||
|
||||
<div class="ck-content">
|
||||
<p>TODO:</p>
|
||||
<ul class="todo-list">
|
||||
<li>
|
||||
<label class="todo-list__label">
|
||||
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description"> </span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`).split("\n").filter((l) => l.trim()).join("\n");
|
||||
const expected = trimIndentation`\
|
||||
<body>
|
||||
<p>TODO:</p>
|
||||
<ul class="todo-list">
|
||||
<li>
|
||||
<label class="todo-list__label">
|
||||
<input type="checkbox" disabled="disabled"><span class="todo-list__label__description"> </span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</body>`;
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,678 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import BAttribute from "../../becca/entities/battribute.js";
|
||||
import { removeTextFileExtension, newEntityId, getNoteTitle, processStringOrBuffer, unescapeHtml } from "../../services/utils.js";
|
||||
import log from "../../services/log.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import attributeService from "../../services/attributes.js";
|
||||
import BBranch from "../../becca/entities/bbranch.js";
|
||||
import path from "path";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import mimeService from "./mime.js";
|
||||
import treeService from "../tree.js";
|
||||
import yauzl from "yauzl";
|
||||
import htmlSanitizer from "../html_sanitizer.js";
|
||||
import becca from "../../becca/becca.js";
|
||||
import BAttachment from "../../becca/entities/battachment.js";
|
||||
import markdownService from "./markdown.js";
|
||||
import type TaskContext from "../task_context.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type NoteMeta from "../meta/note_meta.js";
|
||||
import type AttributeMeta from "../meta/attribute_meta.js";
|
||||
import type { Stream } from "stream";
|
||||
import { ALLOWED_NOTE_TYPES, type NoteType } from "@triliumnext/commons";
|
||||
|
||||
interface MetaFile {
|
||||
files: NoteMeta[];
|
||||
}
|
||||
|
||||
interface ImportZipOpts {
|
||||
preserveIds?: boolean;
|
||||
}
|
||||
|
||||
async function importZip(taskContext: TaskContext, fileBuffer: Buffer, importRootNote: BNote, opts?: ImportZipOpts): Promise<BNote> {
|
||||
/** maps from original noteId (in ZIP file) to newly generated noteId */
|
||||
const noteIdMap: Record<string, string> = {};
|
||||
/** type maps from original attachmentId (in ZIP file) to newly generated attachmentId */
|
||||
const attachmentIdMap: Record<string, string> = {};
|
||||
const attributes: AttributeMeta[] = [];
|
||||
// path => noteId, used only when meta file is not available
|
||||
/** path => noteId | attachmentId */
|
||||
const createdPaths: Record<string, string> = { "/": importRootNote.noteId, "\\": importRootNote.noteId };
|
||||
let metaFile: MetaFile | null = null;
|
||||
let firstNote: BNote | null = null;
|
||||
const createdNoteIds = new Set<string>();
|
||||
|
||||
function getNewNoteId(origNoteId: string) {
|
||||
if (!origNoteId.trim()) {
|
||||
// this probably shouldn't happen, but still good to have this precaution
|
||||
return "empty_note_id";
|
||||
}
|
||||
|
||||
if (origNoteId === "root" || origNoteId.startsWith("_") || opts?.preserveIds) {
|
||||
// these "named" noteIds don't differ between Trilium instances
|
||||
return origNoteId;
|
||||
}
|
||||
|
||||
if (!noteIdMap[origNoteId]) {
|
||||
noteIdMap[origNoteId] = newEntityId();
|
||||
}
|
||||
|
||||
return noteIdMap[origNoteId];
|
||||
}
|
||||
|
||||
function getNewAttachmentId(origAttachmentId: string) {
|
||||
if (opts?.preserveIds) {
|
||||
return origAttachmentId;
|
||||
}
|
||||
|
||||
if (!origAttachmentId.trim()) {
|
||||
// this probably shouldn't happen, but still good to have this precaution
|
||||
return "empty_attachment_id";
|
||||
}
|
||||
|
||||
if (!attachmentIdMap[origAttachmentId]) {
|
||||
attachmentIdMap[origAttachmentId] = newEntityId();
|
||||
}
|
||||
|
||||
return attachmentIdMap[origAttachmentId];
|
||||
}
|
||||
|
||||
function getAttachmentMeta(parentNoteMeta: NoteMeta, dataFileName: string) {
|
||||
for (const noteMeta of parentNoteMeta.children || []) {
|
||||
for (const attachmentMeta of noteMeta.attachments || []) {
|
||||
if (attachmentMeta.dataFileName === dataFileName) {
|
||||
return {
|
||||
parentNoteMeta,
|
||||
noteMeta,
|
||||
attachmentMeta
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function getMeta(filePath: string) {
|
||||
if (!metaFile) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const pathSegments = filePath.split(/[\/\\]/g);
|
||||
|
||||
let cursor: NoteMeta | undefined = {
|
||||
isImportRoot: true,
|
||||
children: metaFile.files,
|
||||
dataFileName: ""
|
||||
};
|
||||
|
||||
let parent: NoteMeta | undefined = undefined;
|
||||
|
||||
for (let segment of pathSegments) {
|
||||
if (!cursor?.children?.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
segment = unescapeHtml(segment);
|
||||
parent = cursor;
|
||||
if (parent.children) {
|
||||
cursor = parent.children.find((file) => file.dataFileName === segment || file.dirFileName === segment);
|
||||
}
|
||||
|
||||
if (!cursor) {
|
||||
return getAttachmentMeta(parent, segment);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parentNoteMeta: parent,
|
||||
noteMeta: cursor,
|
||||
attachmentMeta: null
|
||||
};
|
||||
}
|
||||
|
||||
function getParentNoteId(filePath: string, parentNoteMeta?: NoteMeta) {
|
||||
let parentNoteId;
|
||||
|
||||
if (parentNoteMeta?.noteId) {
|
||||
parentNoteId = parentNoteMeta.isImportRoot ? importRootNote.noteId : getNewNoteId(parentNoteMeta.noteId);
|
||||
} else {
|
||||
const parentPath = path.dirname(filePath);
|
||||
|
||||
if (parentPath === ".") {
|
||||
parentNoteId = importRootNote.noteId;
|
||||
} else if (parentPath in createdPaths) {
|
||||
parentNoteId = createdPaths[parentPath];
|
||||
} else {
|
||||
// ZIP allows creating out of order records - i.e., file in a directory can appear in the ZIP stream before the actual directory
|
||||
parentNoteId = saveDirectory(parentPath);
|
||||
}
|
||||
}
|
||||
|
||||
return parentNoteId;
|
||||
}
|
||||
|
||||
function getNoteId(noteMeta: NoteMeta | undefined, filePath: string): string {
|
||||
if (noteMeta?.noteId) {
|
||||
return getNewNoteId(noteMeta.noteId);
|
||||
}
|
||||
|
||||
// in case we lack metadata, we treat e.g. "Programming.html" and "Programming" as the same note
|
||||
// (one data file, the other directory for children)
|
||||
const filePathNoExt = removeTextFileExtension(filePath);
|
||||
|
||||
if (filePathNoExt in createdPaths) {
|
||||
return createdPaths[filePathNoExt];
|
||||
}
|
||||
|
||||
const noteId = newEntityId();
|
||||
|
||||
createdPaths[filePathNoExt] = noteId;
|
||||
|
||||
return noteId;
|
||||
}
|
||||
|
||||
function detectFileTypeAndMime(taskContext: TaskContext, filePath: string) {
|
||||
const mime = mimeService.getMime(filePath) || "application/octet-stream";
|
||||
const type = mimeService.getType(taskContext.data || {}, mime);
|
||||
|
||||
return { mime, type };
|
||||
}
|
||||
|
||||
function saveAttributes(note: BNote, noteMeta: NoteMeta | undefined) {
|
||||
if (!noteMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const attr of noteMeta.attributes || []) {
|
||||
attr.noteId = note.noteId;
|
||||
|
||||
if (attr.type === "label-definition") {
|
||||
attr.type = "label";
|
||||
attr.name = `label:${attr.name}`;
|
||||
} else if (attr.type === "relation-definition") {
|
||||
attr.type = "label";
|
||||
attr.name = `relation:${attr.name}`;
|
||||
}
|
||||
|
||||
if (!attributeService.isAttributeType(attr.type)) {
|
||||
log.error(`Unrecognized attribute type ${attr.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attr.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(attr.name)) {
|
||||
// these relations are created automatically and as such don't need to be duplicated in the import
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attr.type === "relation") {
|
||||
attr.value = getNewNoteId(attr.value);
|
||||
}
|
||||
|
||||
if (taskContext.data?.safeImport && attributeService.isAttributeDangerous(attr.type, attr.name)) {
|
||||
attr.name = `disabled:${attr.name}`;
|
||||
}
|
||||
|
||||
if (taskContext.data?.safeImport) {
|
||||
attr.name = htmlSanitizer.sanitize(attr.name);
|
||||
attr.value = htmlSanitizer.sanitize(attr.value);
|
||||
}
|
||||
|
||||
attributes.push(attr);
|
||||
}
|
||||
}
|
||||
|
||||
function saveDirectory(filePath: string) {
|
||||
const { parentNoteMeta, noteMeta } = getMeta(filePath);
|
||||
|
||||
const noteId = getNoteId(noteMeta, filePath);
|
||||
|
||||
if (becca.getNote(noteId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteTitle = getNoteTitle(filePath, !!taskContext.data?.replaceUnderscoresWithSpaces, noteMeta);
|
||||
const parentNoteId = getParentNoteId(filePath, parentNoteMeta);
|
||||
|
||||
if (!parentNoteId) {
|
||||
throw new Error("Missing parent note ID.");
|
||||
}
|
||||
|
||||
const { note } = noteService.createNewNote({
|
||||
parentNoteId: parentNoteId,
|
||||
title: noteTitle || "",
|
||||
content: "",
|
||||
noteId: noteId,
|
||||
type: resolveNoteType(noteMeta?.type),
|
||||
mime: noteMeta ? noteMeta.mime : "text/html",
|
||||
prefix: noteMeta?.prefix || "",
|
||||
isExpanded: !!noteMeta?.isExpanded,
|
||||
notePosition: noteMeta && firstNote ? noteMeta.notePosition : undefined,
|
||||
isProtected: importRootNote.isProtected && protectedSessionService.isProtectedSessionAvailable()
|
||||
});
|
||||
|
||||
createdNoteIds.add(note.noteId);
|
||||
|
||||
saveAttributes(note, noteMeta);
|
||||
|
||||
firstNote = firstNote || note;
|
||||
|
||||
return noteId;
|
||||
}
|
||||
|
||||
function getEntityIdFromRelativeUrl(url: string, filePath: string) {
|
||||
while (url.startsWith("./")) {
|
||||
url = url.substr(2);
|
||||
}
|
||||
|
||||
let absUrl = path.dirname(filePath);
|
||||
|
||||
while (url.startsWith("../")) {
|
||||
absUrl = path.dirname(absUrl);
|
||||
|
||||
url = url.substr(3);
|
||||
}
|
||||
|
||||
if (absUrl === ".") {
|
||||
absUrl = "";
|
||||
}
|
||||
|
||||
absUrl += `${absUrl.length > 0 ? "/" : ""}${url}`;
|
||||
|
||||
const { noteMeta, attachmentMeta } = getMeta(absUrl);
|
||||
|
||||
if (attachmentMeta && attachmentMeta.attachmentId && noteMeta.noteId) {
|
||||
return {
|
||||
attachmentId: getNewAttachmentId(attachmentMeta.attachmentId),
|
||||
noteId: getNewNoteId(noteMeta.noteId)
|
||||
};
|
||||
} else {
|
||||
// don't check for noteMeta since it's not mandatory for notes
|
||||
return {
|
||||
noteId: getNoteId(noteMeta, absUrl)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function processTextNoteContent(content: string, noteTitle: string, filePath: string, noteMeta?: NoteMeta) {
|
||||
function isUrlAbsolute(url: string) {
|
||||
return /^(?:[a-z]+:)?\/\//i.test(url);
|
||||
}
|
||||
|
||||
content = removeTriliumTags(content);
|
||||
|
||||
content = content.replace(/<h1>([^<]*)<\/h1>/gi, (match, text) => {
|
||||
if (noteTitle.trim() === text.trim()) {
|
||||
return ""; // remove whole H1 tag
|
||||
} else {
|
||||
return `<h2>${text}</h2>`;
|
||||
}
|
||||
});
|
||||
|
||||
if (taskContext.data?.safeImport) {
|
||||
content = htmlSanitizer.sanitize(content);
|
||||
}
|
||||
|
||||
content = content.replace(/<html.*<body[^>]*>/gis, "");
|
||||
content = content.replace(/<\/body>.*<\/html>/gis, "");
|
||||
|
||||
content = content.replace(/src="([^"]*)"/g, (match, url) => {
|
||||
if (url.startsWith("data:image")) {
|
||||
// inline images are parsed and saved into attachments in the note service
|
||||
return match;
|
||||
}
|
||||
|
||||
try {
|
||||
url = decodeURIComponent(url).trim();
|
||||
} catch (e: any) {
|
||||
log.error(`Cannot parse image URL '${url}', keeping original. Error: ${e.message}.`);
|
||||
return `src="${url}"`;
|
||||
}
|
||||
|
||||
if (isUrlAbsolute(url) || url.startsWith("/")) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const target = getEntityIdFromRelativeUrl(url, filePath);
|
||||
|
||||
if (target.attachmentId) {
|
||||
return `src="api/attachments/${target.attachmentId}/image/${path.basename(url)}"`;
|
||||
} else if (target.noteId) {
|
||||
return `src="api/images/${target.noteId}/${path.basename(url)}"`;
|
||||
} else {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
|
||||
content = content.replace(/href="([^"]*)"/g, (match, url) => {
|
||||
try {
|
||||
url = decodeURIComponent(url).trim();
|
||||
} catch (e: any) {
|
||||
log.error(`Cannot parse link URL '${url}', keeping original. Error: ${e.message}.`);
|
||||
return `href="${url}"`;
|
||||
}
|
||||
|
||||
if (
|
||||
url.startsWith("#") || // already a note path (probably)
|
||||
isUrlAbsolute(url)
|
||||
) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const target = getEntityIdFromRelativeUrl(url, filePath);
|
||||
|
||||
if (target.attachmentId) {
|
||||
return `href="#root/${target.noteId}?viewMode=attachments&attachmentId=${target.attachmentId}"`;
|
||||
} else if (target.noteId) {
|
||||
return `href="#root/${target.noteId}"`;
|
||||
} else {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
|
||||
if (noteMeta) {
|
||||
const includeNoteLinks = (noteMeta.attributes || []).filter((attr) => attr.type === "relation" && attr.name === "includeNoteLink");
|
||||
|
||||
for (const link of includeNoteLinks) {
|
||||
// no need to escape the regexp find string since it's a noteId which doesn't contain any special characters
|
||||
content = content.replace(new RegExp(link.value, "g"), getNewNoteId(link.value));
|
||||
}
|
||||
}
|
||||
|
||||
content = content.trim();
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function processNoteContent(noteMeta: NoteMeta | undefined, type: string, mime: string, content: string | Buffer, noteTitle: string, filePath: string) {
|
||||
if ((noteMeta?.format === "markdown" || (!noteMeta && taskContext.data?.textImportedAsText && ["text/markdown", "text/x-markdown", "text/mdx"].includes(mime))) && typeof content === "string") {
|
||||
content = markdownService.renderToHtml(content, noteTitle);
|
||||
}
|
||||
|
||||
if (type === "text" && typeof content === "string") {
|
||||
content = processTextNoteContent(content, noteTitle, filePath, noteMeta);
|
||||
}
|
||||
|
||||
if (type === "relationMap" && noteMeta && typeof content === "string") {
|
||||
const relationMapLinks = (noteMeta.attributes || []).filter((attr) => attr.type === "relation" && attr.name === "relationMapLink");
|
||||
|
||||
// this will replace relation map links
|
||||
for (const link of relationMapLinks) {
|
||||
// no need to escape the regexp find string since it's a noteId which doesn't contain any special characters
|
||||
content = content.replace(new RegExp(link.value, "g"), getNewNoteId(link.value));
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function saveNote(filePath: string, content: string | Buffer) {
|
||||
const { parentNoteMeta, noteMeta, attachmentMeta } = getMeta(filePath);
|
||||
|
||||
if (noteMeta?.noImport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteId = getNoteId(noteMeta, filePath);
|
||||
|
||||
if (attachmentMeta && attachmentMeta.attachmentId) {
|
||||
const attachment = new BAttachment({
|
||||
attachmentId: getNewAttachmentId(attachmentMeta.attachmentId),
|
||||
ownerId: noteId,
|
||||
title: attachmentMeta.title,
|
||||
role: attachmentMeta.role,
|
||||
mime: attachmentMeta.mime,
|
||||
position: attachmentMeta.position
|
||||
});
|
||||
|
||||
attachment.setContent(content, { forceSave: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const parentNoteId = getParentNoteId(filePath, parentNoteMeta);
|
||||
|
||||
if (!parentNoteId) {
|
||||
throw new Error(`Cannot find parentNoteId for '${filePath}'`);
|
||||
}
|
||||
|
||||
if (noteMeta?.isClone) {
|
||||
if (!becca.getBranchFromChildAndParent(noteId, parentNoteId)) {
|
||||
new BBranch({
|
||||
noteId,
|
||||
parentNoteId,
|
||||
isExpanded: noteMeta.isExpanded,
|
||||
prefix: noteMeta.prefix,
|
||||
notePosition: noteMeta.notePosition
|
||||
}).save();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let { mime, type: detectedType } = noteMeta ? noteMeta : detectFileTypeAndMime(taskContext, filePath);
|
||||
const type = resolveNoteType(detectedType);
|
||||
if (mime == null) {
|
||||
throw new Error("Unable to resolve mime type.");
|
||||
}
|
||||
|
||||
if (type !== "file" && type !== "image") {
|
||||
content = processStringOrBuffer(content);
|
||||
}
|
||||
|
||||
const noteTitle = getNoteTitle(filePath, taskContext.data?.replaceUnderscoresWithSpaces || false, noteMeta);
|
||||
|
||||
content = processNoteContent(noteMeta, type, mime, content, noteTitle || "", filePath);
|
||||
|
||||
let note = becca.getNote(noteId);
|
||||
|
||||
const isProtected = importRootNote.isProtected && protectedSessionService.isProtectedSessionAvailable();
|
||||
|
||||
if (note) {
|
||||
// only skeleton was created because of altered order of cloned notes in ZIP, we need to update
|
||||
// https://github.com/zadam/trilium/issues/2440
|
||||
if (note.type === undefined) {
|
||||
note.type = type;
|
||||
note.mime = mime;
|
||||
note.title = noteTitle || "";
|
||||
note.isProtected = isProtected;
|
||||
note.save();
|
||||
}
|
||||
|
||||
note.setContent(content);
|
||||
|
||||
if (!becca.getBranchFromChildAndParent(noteId, parentNoteId)) {
|
||||
new BBranch({
|
||||
noteId,
|
||||
parentNoteId,
|
||||
isExpanded: noteMeta?.isExpanded,
|
||||
prefix: noteMeta?.prefix,
|
||||
notePosition: noteMeta?.notePosition
|
||||
}).save();
|
||||
}
|
||||
|
||||
if (opts?.preserveIds) {
|
||||
firstNote = firstNote || note;
|
||||
}
|
||||
} else {
|
||||
({ note } = noteService.createNewNote({
|
||||
parentNoteId: parentNoteId,
|
||||
title: noteTitle || "",
|
||||
content: content,
|
||||
noteId,
|
||||
type,
|
||||
mime,
|
||||
prefix: noteMeta?.prefix || "",
|
||||
isExpanded: !!noteMeta?.isExpanded,
|
||||
// root notePosition should be ignored since it relates to the original document
|
||||
// now import root should be placed after existing notes into new parent
|
||||
notePosition: noteMeta && firstNote ? noteMeta.notePosition : undefined,
|
||||
isProtected: isProtected
|
||||
}));
|
||||
|
||||
createdNoteIds.add(note.noteId);
|
||||
|
||||
saveAttributes(note, noteMeta);
|
||||
|
||||
firstNote = firstNote || note;
|
||||
}
|
||||
|
||||
if (!noteMeta && (type === "file" || type === "image")) {
|
||||
attributes.push({
|
||||
noteId,
|
||||
type: "label",
|
||||
name: "originalFileName",
|
||||
value: path.basename(filePath)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// we're running two passes to make sure that the meta file is loaded before the rest of the files is processed.
|
||||
|
||||
await readZipFile(fileBuffer, async (zipfile: yauzl.ZipFile, entry: yauzl.Entry) => {
|
||||
const filePath = normalizeFilePath(entry.fileName);
|
||||
|
||||
if (filePath === "!!!meta.json") {
|
||||
const content = await readContent(zipfile, entry);
|
||||
|
||||
metaFile = JSON.parse(content.toString("utf-8"));
|
||||
}
|
||||
|
||||
zipfile.readEntry();
|
||||
});
|
||||
|
||||
await readZipFile(fileBuffer, async (zipfile: yauzl.ZipFile, entry: yauzl.Entry) => {
|
||||
const filePath = normalizeFilePath(entry.fileName);
|
||||
|
||||
if (/\/$/.test(entry.fileName)) {
|
||||
saveDirectory(filePath);
|
||||
} else if (filePath !== "!!!meta.json") {
|
||||
const content = await readContent(zipfile, entry);
|
||||
|
||||
saveNote(filePath, content);
|
||||
}
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
zipfile.readEntry();
|
||||
});
|
||||
|
||||
for (const noteId of createdNoteIds) {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) continue;
|
||||
await noteService.asyncPostProcessContent(note, note.getContent());
|
||||
|
||||
if (!metaFile) {
|
||||
// if there's no meta file, then the notes are created based on the order in that zip file but that
|
||||
// is usually quite random, so we sort the notes in the way they would appear in the file manager
|
||||
treeService.sortNotes(noteId, "title", false, true);
|
||||
}
|
||||
|
||||
taskContext.increaseProgressCount();
|
||||
}
|
||||
|
||||
// we're saving attributes and links only now so that all relation and link target notes
|
||||
// are already in the database (we don't want to have "broken" relations, not even transitionally)
|
||||
for (const attr of attributes) {
|
||||
if (attr.type !== "relation" || attr.value in becca.notes) {
|
||||
new BAttribute(attr).save();
|
||||
} else {
|
||||
log.info(`Relation not imported since the target note doesn't exist: ${JSON.stringify(attr)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstNote) {
|
||||
throw new Error("Unable to determine first note.");
|
||||
}
|
||||
|
||||
return firstNote;
|
||||
}
|
||||
|
||||
/** @returns path without leading or trailing slash and backslashes converted to forward ones */
|
||||
function normalizeFilePath(filePath: string): string {
|
||||
filePath = filePath.replace(/\\/g, "/");
|
||||
|
||||
if (filePath.startsWith("/")) {
|
||||
filePath = filePath.substr(1);
|
||||
}
|
||||
|
||||
if (filePath.endsWith("/")) {
|
||||
filePath = filePath.substr(0, filePath.length - 1);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function streamToBuffer(stream: Stream): Promise<Buffer> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
stream.on("data", (chunk) => chunks.push(chunk));
|
||||
|
||||
return new Promise((res, rej) => stream.on("end", () => res(Buffer.concat(chunks))));
|
||||
}
|
||||
|
||||
export function readContent(zipfile: yauzl.ZipFile, entry: yauzl.Entry): Promise<Buffer> {
|
||||
return new Promise((res, rej) => {
|
||||
zipfile.openReadStream(entry, function (err, readStream) {
|
||||
if (err) rej(err);
|
||||
if (!readStream) throw new Error("Unable to read content.");
|
||||
|
||||
streamToBuffer(readStream).then(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function readZipFile(buffer: Buffer, processEntryCallback: (zipfile: yauzl.ZipFile, entry: yauzl.Entry) => Promise<void>) {
|
||||
return new Promise<void>((res, rej) => {
|
||||
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: false }, function (err, zipfile) {
|
||||
if (err) rej(err);
|
||||
if (!zipfile) throw new Error("Unable to read zip file.");
|
||||
|
||||
zipfile.readEntry();
|
||||
zipfile.on("entry", async (entry) => {
|
||||
try {
|
||||
await processEntryCallback(zipfile, entry);
|
||||
} catch (e) {
|
||||
rej(e);
|
||||
}
|
||||
});
|
||||
zipfile.on("end", res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveNoteType(type: string | undefined): NoteType {
|
||||
// BC for ZIPs created in Trilium 0.57 and older
|
||||
if (type === "relation-map") {
|
||||
return "relationMap";
|
||||
} else if (type === "note-map") {
|
||||
return "noteMap";
|
||||
} else if (type === "web-view") {
|
||||
return "webView";
|
||||
}
|
||||
|
||||
if (type && (ALLOWED_NOTE_TYPES as readonly string[]).includes(type)) {
|
||||
return type as NoteType;
|
||||
} else {
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
export function removeTriliumTags(content: string) {
|
||||
const tagsToRemove = [
|
||||
"<h1 data-trilium-h1>([^<]*)<\/h1>",
|
||||
"<title data-trilium-title>([^<]*)<\/title>"
|
||||
];
|
||||
for (const tag of tagsToRemove) {
|
||||
let re = new RegExp(tag, "gi");
|
||||
content = content.replace(re, "");
|
||||
}
|
||||
|
||||
// Remove ckeditor tags
|
||||
content = content.replace(/<div class="ck-content">(.*)<\/div>/gms, "$1");
|
||||
content = content.replace(/<div class="content">(.*)<\/div>/gms, "$1");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export default {
|
||||
importZip
|
||||
};
|
||||
Reference in New Issue
Block a user