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\attachments2attachments2trueTEST2
+
+
+NO attachments
]]>20260417T081317Z20260417T081345Zdesktop.winevernote.win32TEST3
+
+
+
+111
+]]>20260417T081337Z20260417T081340Zdesktop.winevernote.win32