diff --git a/apps/server/src/services/import/enex.spec.ts b/apps/server/src/services/import/enex.spec.ts new file mode 100644 index 0000000000..a95baafec9 --- /dev/null +++ b/apps/server/src/services/import/enex.spec.ts @@ -0,0 +1,90 @@ +import fs from "fs"; +import { default as path, dirname } from "path"; +import { fileURLToPath } from "url"; +import { beforeAll, describe, expect, it } from "vitest"; + +import becca from "../../becca/becca.js"; +import type BNote from "../../becca/entities/bnote.js"; +import cls from "../cls.js"; +import sql_init from "../sql_init.js"; +import TaskContext from "../task_context.js"; +import enex from "./enex.js"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +async function testImport(fileName: string) { + const sample = fs.readFileSync(path.join(scriptDir, "samples", fileName)); + const taskContext = TaskContext.getInstance("import-enex", "importNotes", {}); + + 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 enex.importEnex(taskContext, { + originalname: fileName, + mimetype: "application/enex+xml", + buffer: sample + }, rootNote as BNote); + resolve({ + importedNote, + rootNote + }); + }); + }); +} + +describe("importEnex", () => { + beforeAll(async () => { + sql_init.initializeDb(); + await sql_init.dbReady; + }); + + it("imports non-image resources as attachments instead of child notes", async () => { + const { importedNote } = await testImport("File with attachments.enex"); + + // The root import note should contain the individual notes as children + const test1 = importedNote.getChildNotes().find(n => n.title === "TEST1"); + expect(test1).toBeTruthy(); + + // Non-image resources should be attachments, not child notes + const childNotes = test1!.getChildNotes(); + expect(childNotes).toHaveLength(0); + + // Should have two file attachments + const attachments = test1!.getAttachmentsByRole("file"); + expect(attachments).toHaveLength(2); + + const txt = attachments.find(a => a.title === "attachments1.txt"); + expect(txt).toBeTruthy(); + expect(txt!.mime).toBe("text/plain"); + expect(txt!.getContent().toString()).toBe("111"); + + const bin = attachments.find(a => a.title === "attachments2"); + expect(bin).toBeTruthy(); + expect(bin!.mime).toBe("application/octet-stream"); + expect(bin!.getContent().toString()).toBe("222"); + + // The note content should contain reference links to the attachments + const content = test1!.getContent().toString(); + expect(content).toContain(`class="reference-link" href="#root/${test1!.noteId}?viewMode=attachments&attachmentId=${txt!.attachmentId}"`); + expect(content).toContain(`class="reference-link" href="#root/${test1!.noteId}?viewMode=attachments&attachmentId=${bin!.attachmentId}"`); + }); + + it("imports notes without attachments normally", async () => { + const { importedNote } = await testImport("File with attachments.enex"); + + const test2 = importedNote.getChildNotes().find(n => n.title === "TEST2"); + expect(test2).toBeTruthy(); + expect(test2!.getChildNotes()).toHaveLength(0); + expect(test2!.getAttachmentsByRole("file")).toHaveLength(0); + + const test3 = importedNote.getChildNotes().find(n => n.title === "TEST3"); + expect(test3).toBeTruthy(); + expect(test3!.getChildNotes()).toHaveLength(0); + expect(test3!.getAttachmentsByRole("file")).toHaveLength(0); + }); +}, 60_000); diff --git a/apps/server/src/services/import/enex.ts b/apps/server/src/services/import/enex.ts index 5f9166fce3..2918883964 100644 --- a/apps/server/src/services/import/enex.ts +++ b/apps/server/src/services/import/enex.ts @@ -1,20 +1,21 @@ +import type { AttributeType } from "@triliumnext/commons"; import { dayjs } from "@triliumnext/commons"; 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 date_utils from "../date_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 date_utils from "../date_utils.js"; +import htmlSanitizer from "../html_sanitizer.js"; +import imageService from "../image.js"; +import log from "../log.js"; +import noteService from "../notes.js"; +import protectedSessionService from "../protected_session.js"; +import sanitizeAttributeName from "../sanitize_attribute_name.js"; +import sql from "../sql.js"; +import type TaskContext from "../task_context.js"; +import { escapeHtml, fromBase64,md5 } from "../utils.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) @@ -25,7 +26,7 @@ function parseDate(text: string) { 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"; + 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; } @@ -318,27 +319,17 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN resource.mime = resource.mime || "application/octet-stream"; - const createFileNote = () => { - const resourceNote = noteService.createNewNote({ - parentNoteId: noteEntity.noteId, + const createFileAttachment = () => { + const attachment = noteEntity.saveAttachment({ + role: "file", + mime: resource.mime || "application/octet-stream", title: resource.title, - content: resource.content ?? "", - type: "file", - mime: resource.mime, - isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() - }).note; + content: resource.content ?? "" + }); - for (const attr of resource.attributes) { - resourceNote.addAttribute(attr.type, attr.name, attr.value); - } + const attachmentLink = `${escapeHtml(resource.title)}`; - updateDates(resourceNote, utcDateCreated, utcDateModified); - - taskContext.increaseProgressCount(); - - const resourceLink = `${escapeHtml(resource.title)}`; - - content = (content || "").replace(mediaRegex, resourceLink); + content = (content || "").replace(mediaRegex, attachmentLink); }; if (resource.mime && resource.mime.startsWith("image/")) { @@ -360,10 +351,10 @@ function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentN } } catch (e: any) { log.error(`error when saving image from ENEX file: ${e.message}`); - createFileNote(); + createFileAttachment(); } } else { - createFileNote(); + createFileAttachment(); } } diff --git a/apps/server/src/services/import/samples/File with attachments.enex b/apps/server/src/services/import/samples/File with attachments.enex new file mode 100644 index 0000000000..d65423f7d7 --- /dev/null +++ b/apps/server/src/services/import/samples/File with attachments.enex @@ -0,0 +1,19 @@ + + + +TEST1 + + +
TXT

]]>
20260417T081156Z20260417T081307Zdesktop.winevernote.win32 +MTEx +text/plainfile://D:\Users\leon\Desktop\attachments1.txtattachments1.txttrue +MjIy +application/octet-streamfile://D:\Users\leon\Desktop\attachments2attachments2true
TEST2 + + +
NO attachments
]]>
20260417T081317Z20260417T081345Zdesktop.winevernote.win32
TEST3 + + + +111 +]]>20260417T081337Z20260417T081340Zdesktop.winevernote.win32