server-ts: Port services/import/enex

This commit is contained in:
Elian Doran 2024-03-17 21:29:57 +02:00
parent aa233b8adb
commit 764d251b0a
No known key found for this signature in database
7 changed files with 155 additions and 43 deletions

38
package-lock.json generated
View File

@ -99,6 +99,8 @@
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^20.11.19", "@types/node": "^20.11.19",
"@types/sanitize-html": "^2.11.0", "@types/sanitize-html": "^2.11.0",
"@types/sax": "^1.2.7",
"@types/stream-throttle": "^0.1.4",
"@types/turndown": "^5.0.4", "@types/turndown": "^5.0.4",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
@ -1587,6 +1589,15 @@
"entities": "^4.4.0" "entities": "^4.4.0"
} }
}, },
"node_modules/@types/sax": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/send": { "node_modules/@types/send": {
"version": "0.17.4", "version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
@ -1608,6 +1619,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/stream-throttle": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/stream-throttle/-/stream-throttle-0.1.4.tgz",
"integrity": "sha512-VxXIHGjVuK8tYsVm60rIQMmF/0xguCeen5OmK5S4Y6K64A+z+y4/GI6anRnVzaUZaJB9Ah9IfbDcO0o1gZCc/w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/tough-cookie": { "node_modules/@types/tough-cookie": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@ -14511,6 +14531,15 @@
} }
} }
}, },
"@types/sax": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/send": { "@types/send": {
"version": "0.17.4", "version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
@ -14532,6 +14561,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/stream-throttle": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/stream-throttle/-/stream-throttle-0.1.4.tgz",
"integrity": "sha512-VxXIHGjVuK8tYsVm60rIQMmF/0xguCeen5OmK5S4Y6K64A+z+y4/GI6anRnVzaUZaJB9Ah9IfbDcO0o1gZCc/w==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/tough-cookie": { "@types/tough-cookie": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",

View File

@ -120,6 +120,8 @@
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^20.11.19", "@types/node": "^20.11.19",
"@types/sanitize-html": "^2.11.0", "@types/sanitize-html": "^2.11.0",
"@types/sax": "^1.2.7",
"@types/stream-throttle": "^0.1.4",
"@types/turndown": "^5.0.4", "@types/turndown": "^5.0.4",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",

View File

@ -1,6 +1,6 @@
"use strict"; "use strict";
const enexImportService = require('../../services/import/enex.js'); const enexImportService = require('../../services/import/enex');
const opmlImportService = require('../../services/import/opml'); const opmlImportService = require('../../services/import/opml');
const zipImportService = require('../../services/import/zip'); const zipImportService = require('../../services/import/zip');
const singleImportService = require('../../services/import/single'); const singleImportService = require('../../services/import/single');
@ -13,8 +13,8 @@ const TaskContext = require('../../services/task_context');
const ValidationError = require('../../errors/validation_error'); const ValidationError = require('../../errors/validation_error');
async function importNotesToBranch(req) { async function importNotesToBranch(req) {
const {parentNoteId} = req.params; const { parentNoteId } = req.params;
const {taskId, last} = req.body; const { taskId, last } = req.body;
const options = { const options = {
safeImport: req.body.safeImport !== 'false', safeImport: req.body.safeImport !== 'false',
@ -81,8 +81,8 @@ async function importNotesToBranch(req) {
} }
async function importAttachmentsToNote(req) { async function importAttachmentsToNote(req) {
const {parentNoteId} = req.params; const { parentNoteId } = req.params;
const {taskId, last} = req.body; const { taskId, last } = req.body;
const options = { const options = {
shrinkImages: req.body.shrinkImages !== 'false', shrinkImages: req.body.shrinkImages !== 'false',

View File

@ -0,0 +1,5 @@
export interface File {
originalname: string;
mimetype: string;
buffer: string | Buffer;
}

View File

@ -1,20 +1,23 @@
const sax = require("sax"); import sax = require("sax");
const stream = require('stream'); import stream = require('stream');
const {Throttle} = require('stream-throttle'); import { Throttle } from 'stream-throttle';
const log = require('../log'); import log = require('../log');
const utils = require('../utils'); import utils = require('../utils');
const sql = require('../sql'); import sql = require('../sql');
const noteService = require('../notes'); import noteService = require('../notes');
const imageService = require('../image'); import imageService = require('../image');
const protectedSessionService = require('../protected_session'); import protectedSessionService = require('../protected_session');
const htmlSanitizer = require('../html_sanitizer'); import htmlSanitizer = require('../html_sanitizer');
const {sanitizeAttributeName} = require('../sanitize_attribute_name'); import sanitizeAttributeName = require('../sanitize_attribute_name');
import TaskContext = require("../task_context");
import BNote = require("../../becca/entities/bnote");
import { File } from "./common";
/** /**
* date format is e.g. 20181121T193703Z or 2013-04-14T16:19:00.000Z (Mac evernote, see #3496) * 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 * @returns trilium date format, e.g. 2013-04-14 16:19:00.000Z
*/ */
function parseDate(text) { function parseDate(text: string) {
// convert ISO format to the "20181121T193703Z" format // convert ISO format to the "20181121T193703Z" format
text = text.replace(/[-:]/g, ""); text = text.replace(/[-:]/g, "");
@ -25,10 +28,34 @@ function parseDate(text) {
return text; return text;
} }
let note = {}; interface Attribute {
let resource; type: string;
name: string;
value: string;
}
function importEnex(taskContext, file, parentNote) { 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) {
const saxStream = sax.createStream(true); const saxStream = sax.createStream(true);
const rootNoteTitle = file.originalname.toLowerCase().endsWith(".enex") const rootNoteTitle = file.originalname.toLowerCase().endsWith(".enex")
@ -45,7 +72,7 @@ function importEnex(taskContext, file, parentNote) {
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable(), isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable(),
}).note; }).note;
function extractContent(content) { function extractContent(content: string) {
const openingNoteIndex = content.indexOf('<en-note>'); const openingNoteIndex = content.indexOf('<en-note>');
if (openingNoteIndex !== -1) { if (openingNoteIndex !== -1) {
@ -90,7 +117,7 @@ function importEnex(taskContext, file, parentNote) {
} }
const path = []; const path: string[] = [];
function getCurrentTag() { function getCurrentTag() {
if (path.length >= 1) { if (path.length >= 1) {
@ -108,8 +135,8 @@ function importEnex(taskContext, file, parentNote) {
// unhandled errors will throw, since this is a proper node event emitter. // unhandled errors will throw, since this is a proper node event emitter.
log.error(`error when parsing ENEX file: ${e}`); log.error(`error when parsing ENEX file: ${e}`);
// clear the error // clear the error
this._parser.error = null; (saxStream._parser as any).error = null;
this._parser.resume(); saxStream._parser.resume();
}); });
saxStream.on("text", text => { saxStream.on("text", text => {
@ -123,13 +150,15 @@ function importEnex(taskContext, file, parentNote) {
labelName = 'pageUrl'; labelName = 'pageUrl';
} }
labelName = sanitizeAttributeName(labelName); labelName = sanitizeAttributeName.sanitizeAttributeName(labelName || "");
note.attributes.push({ if (note.attributes) {
type: 'label', note.attributes.push({
name: labelName, type: 'label',
value: text name: labelName,
}); value: text
});
}
} }
else if (previousTag === 'resource-attributes') { else if (previousTag === 'resource-attributes') {
if (currentTag === 'file-name') { if (currentTag === 'file-name') {
@ -169,10 +198,10 @@ function importEnex(taskContext, file, parentNote) {
note.utcDateCreated = parseDate(text); note.utcDateCreated = parseDate(text);
} else if (currentTag === 'updated') { } else if (currentTag === 'updated') {
note.utcDateModified = parseDate(text); note.utcDateModified = parseDate(text);
} else if (currentTag === 'tag') { } else if (currentTag === 'tag' && note.attributes) {
note.attributes.push({ note.attributes.push({
type: 'label', type: 'label',
name: sanitizeAttributeName(text), name: sanitizeAttributeName.sanitizeAttributeName(text),
value: '' value: ''
}) })
} }
@ -201,11 +230,13 @@ function importEnex(taskContext, file, parentNote) {
attributes: [] attributes: []
}; };
note.resources.push(resource); if (note.resources) {
note.resources.push(resource);
}
} }
}); });
function updateDates(note, utcDateCreated, utcDateModified) { 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 // it's difficult to force custom dateCreated and dateModified to Note entity, so we do it post-creation with SQL
sql.execute(` sql.execute(`
UPDATE notes UPDATE notes
@ -227,6 +258,10 @@ function importEnex(taskContext, file, parentNote) {
// make a copy because stream continues with the next call and note gets overwritten // make a copy because stream continues with the next call and note gets overwritten
let {title, content, attributes, resources, utcDateCreated, utcDateModified} = note; let {title, content, attributes, resources, utcDateCreated, utcDateModified} = note;
if (!title || !content) {
throw new Error("Missing title or content for note.");
}
content = extractContent(content); content = extractContent(content);
const noteEntity = noteService.createNewNote({ const noteEntity = noteService.createNewNote({
@ -239,7 +274,7 @@ function importEnex(taskContext, file, parentNote) {
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable(), isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable(),
}).note; }).note;
for (const attr of attributes) { for (const attr of attributes || []) {
noteEntity.addAttribute(attr.type, attr.name, attr.value); noteEntity.addAttribute(attr.type, attr.name, attr.value);
} }
@ -249,12 +284,14 @@ function importEnex(taskContext, file, parentNote) {
taskContext.increaseProgressCount(); taskContext.increaseProgressCount();
for (const resource of resources) { for (const resource of resources || []) {
if (!resource.content) { if (!resource.content) {
continue; continue;
} }
resource.content = utils.fromBase64(resource.content); if (typeof resource.content === "string") {
resource.content = utils.fromBase64(resource.content);
}
const hash = utils.md5(resource.content); const hash = utils.md5(resource.content);
@ -273,6 +310,10 @@ function importEnex(taskContext, file, parentNote) {
resource.mime = resource.mime || "application/octet-stream"; resource.mime = resource.mime || "application/octet-stream";
const createFileNote = () => { const createFileNote = () => {
if (typeof resource.content !== "string") {
throw new Error("Missing or wrong content type for resource.");
}
const resourceNote = noteService.createNewNote({ const resourceNote = noteService.createNewNote({
parentNoteId: noteEntity.noteId, parentNoteId: noteEntity.noteId,
title: resource.title, title: resource.title,
@ -292,7 +333,7 @@ function importEnex(taskContext, file, parentNote) {
const resourceLink = `<a href="#root/${resourceNote.noteId}">${utils.escapeHtml(resource.title)}</a>`; const resourceLink = `<a href="#root/${resourceNote.noteId}">${utils.escapeHtml(resource.title)}</a>`;
content = content.replace(mediaRegex, resourceLink); content = (content || "").replace(mediaRegex, resourceLink);
}; };
if (resource.mime && resource.mime.startsWith('image/')) { if (resource.mime && resource.mime.startsWith('image/')) {
@ -301,7 +342,7 @@ function importEnex(taskContext, file, parentNote) {
? resource.title ? resource.title
: `image.${resource.mime.substr(6)}`; // default if real name is not present : `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 attachment = imageService.saveImageToAttachment(noteEntity.noteId, resource.content, originalName, !!taskContext.data?.shrinkImages);
const encodedTitle = encodeURIComponent(attachment.title); const encodedTitle = encodeURIComponent(attachment.title);
const url = `api/attachments/${attachment.attachmentId}/image/${encodedTitle}`; const url = `api/attachments/${attachment.attachmentId}/image/${encodedTitle}`;
@ -314,7 +355,7 @@ function importEnex(taskContext, file, parentNote) {
// otherwise the image would be removed since no note would include it // otherwise the image would be removed since no note would include it
content += imageLink; content += imageLink;
} }
} catch (e) { } catch (e: any) {
log.error(`error when saving image from ENEX file: ${e.message}`); log.error(`error when saving image from ENEX file: ${e.message}`);
createFileNote(); createFileNote();
} }
@ -368,4 +409,4 @@ function importEnex(taskContext, file, parentNote) {
}); });
} }
module.exports = { importEnex }; export = { importEnex };

View File

@ -0,0 +1,25 @@
import { NoteType } from "../becca/entities/rows";
export interface NoteParams {
/** optionally can force specific noteId */
noteId?: string;
parentNoteId: string;
templateNoteId?: string;
title: string;
content: string;
type: NoteType;
/** default value is derived from default mimes for type */
mime?: string;
/** default is false */
isProtected?: boolean;
/** default is false */
isExpanded?: boolean;
/** default is empty string */
prefix?: string;
/** default is the last existing notePosition in a parent + 10 */
notePosition?: number;
dateCreated?: string;
utcDateCreated?: string;
ignoreForbiddenParents?: boolean;
target?: "into";
}

View File

@ -30,10 +30,11 @@ let lastSyncedPush: number | null = null;
interface Message { interface Message {
type: string; type: string;
data?: TaskData | null | { data?: {
lastSyncedPush?: number | null, lastSyncedPush?: number | null,
entityChanges?: any[], entityChanges?: any[],
}, shrinkImages?: boolean
} | null,
lastSyncedPush?: number | null, lastSyncedPush?: number | null,
progressCount?: number; progressCount?: number;