"use strict"; const html = require('html'); const dateUtils = require('../date_utils'); const path = require('path'); const mimeTypes = require('mime-types'); const mdService = require('./md'); const packageInfo = require('../../../package.json'); const utils = require('../utils'); const protectedSessionService = require('../protected_session'); const sanitize = require("sanitize-filename"); const fs = require("fs"); const becca = require("../../becca/becca"); const RESOURCE_DIR = require('../../services/resource_dir').RESOURCE_DIR; const archiver = require('archiver'); const log = require("../log"); const TaskContext = require("../task_context"); const ValidationError = require("../../errors/validation_error"); /** * @param {TaskContext} taskContext * @param {BBranch} branch * @param {string} format - 'html' or 'markdown' * @param {object} res - express response * @param {boolean} setHeaders */ async function exportToZip(taskContext, branch, format, res, setHeaders = true) { if (!['html', 'markdown'].includes(format)) { throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`); } const archive = archiver('zip', { zlib: { level: 9 } // Sets the compression level. }); const noteIdToMeta = {}; function getUniqueFilename(existingFileNames, fileName) { const lcFileName = fileName.toLowerCase(); if (lcFileName in existingFileNames) { let index; let newName; do { index = existingFileNames[lcFileName]++; newName = `${index}_${lcFileName}`; } while (newName in existingFileNames); return `${index}_${fileName}`; } else { existingFileNames[lcFileName] = 1; return fileName; } } function getDataFileName(type, mime, baseFileName, existingFileNames) { let fileName = baseFileName; let existingExtension = path.extname(fileName).toLowerCase(); let newExtension; if (fileName.length > 30) { fileName = fileName.substr(0, 30); } // following two are handled specifically since we always want to have these extensions no matter the automatic detection // and/or existing detected extensions in the note name if (type === 'text' && format === 'markdown') { newExtension = 'md'; } else if (type === 'text' && format === 'html') { newExtension = 'html'; } else if (mime === 'application/x-javascript' || mime === 'text/javascript') { newExtension = 'js'; } else if (existingExtension.length > 0) { // if the page already has an extension, then we'll just keep it newExtension = null; } else { if (mime?.toLowerCase()?.trim() === "image/jpg") { newExtension = 'jpg'; } else if (mime?.toLowerCase()?.trim() === "text/mermaid") { newExtension = 'txt'; } else { newExtension = mimeTypes.extension(mime) || "dat"; } } // if the note is already named with extension (e.g. "jquery"), then it's silly to append exact same extension again if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) { fileName += `.${newExtension}`; } return getUniqueFilename(existingFileNames, fileName); } function getNoteMeta(branch, parentMeta, existingFileNames) { const note = branch.getNote(); if (note.hasOwnedLabel('excludeFromExport')) { return; } const title = note.getTitleOrProtected(); const completeTitle = branch.prefix ? (`${branch.prefix} - ${title}`) : title; let baseFileName = sanitize(completeTitle); if (baseFileName.length > 200) { // actual limit is 256 bytes(!) but let's be conservative baseFileName = baseFileName.substr(0, 200); } const notePath = parentMeta.notePath.concat([note.noteId]); if (note.noteId in noteIdToMeta) { const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === 'html' ? 'html' : 'md'}`); return { isClone: true, noteId: note.noteId, notePath: notePath, title: note.getTitleOrProtected(), prefix: branch.prefix, dataFileName: fileName, type: 'text', // export will have text description, format: format }; } const meta = { isClone: false, noteId: note.noteId, notePath: notePath, title: note.getTitleOrProtected(), notePosition: branch.notePosition, prefix: branch.prefix, isExpanded: branch.isExpanded, type: note.type, mime: note.mime, // we don't export utcDateCreated and utcDateModified of any entity since that would be a bit misleading attributes: note.getOwnedAttributes().map(attribute => ({ type: attribute.type, name: attribute.name, value: attribute.value, isInheritable: attribute.isInheritable, position: attribute.position })) }; taskContext.increaseProgressCount(); if (note.type === 'text') { meta.format = format; } noteIdToMeta[note.noteId] = meta; const childBranches = note.getChildBranches() .filter(branch => branch.noteId !== '_hidden'); const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable(); // if it's a leaf then we'll export it even if it's empty if (available && (note.getContent().length > 0 || childBranches.length === 0)) { meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames); } const ancillaries = note.getNoteAncillaries(); if (ancillaries.length > 0) { meta.ancillaries = ancillaries .filter(ancillary => ["canvasSvg", "mermaidSvg"].includes(ancillary.name)) .map(ancillary => ({ name: ancillary.name, mime: ancillary.mime, dataFileName: getDataFileName( null, ancillary.mime, baseFileName + "_" + ancillary.name, existingFileNames ) })); } if (childBranches.length > 0) { meta.dirFileName = getUniqueFilename(existingFileNames, baseFileName); meta.children = []; // namespace is shared by children in the same note const childExistingNames = {}; for (const childBranch of childBranches) { const note = getNoteMeta(childBranch, meta, childExistingNames); // can be undefined if export is disabled for this note if (note) { meta.children.push(note); } } } return meta; } function getTargetUrl(targetNoteId, sourceMeta) { const targetMeta = noteIdToMeta[targetNoteId]; if (!targetMeta) { return null; } const targetPath = targetMeta.notePath.slice(); const sourcePath = sourceMeta.notePath.slice(); // > 1 for edge case that targetPath and sourcePath are exact same (link to itself) while (targetPath.length > 1 && sourcePath.length > 1 && targetPath[0] === sourcePath[0]) { targetPath.shift(); sourcePath.shift(); } let url = "../".repeat(sourcePath.length - 1); for (let i = 0; i < targetPath.length - 1; i++) { const meta = noteIdToMeta[targetPath[i]]; url += `${encodeURIComponent(meta.dirFileName)}/`; } const meta = noteIdToMeta[targetPath[targetPath.length - 1]]; // link can target note which is only "folder-note" and as such will not have a file in an export url += encodeURIComponent(meta.dataFileName || meta.dirFileName); return url; } function findLinks(content, noteMeta) { content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => { const url = getTargetUrl(targetNoteId, noteMeta); return url ? `src="${url}"` : match; }); content = content.replace(/href="[^"]*#root[a-zA-Z0-9_\/]*\/([a-zA-Z0-9_]+)\/?"/g, (match, targetNoteId) => { const url = getTargetUrl(targetNoteId, noteMeta); return url ? `href="${url}"` : match; }); return content; } function prepareContent(title, content, noteMeta) { if (['html', 'markdown'].includes(noteMeta.format)) { content = content.toString(); content = findLinks(content, noteMeta); } if (noteMeta.format === 'html') { if (!content.substr(0, 100).toLowerCase().includes(" element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 content = `
This is a clone of a note. Go to its primary location.
`; content = prepareContent(noteMeta.title, content, noteMeta); archive.append(content, { name: filePathPrefix + noteMeta.dataFileName }); return; } const note = becca.getNote(noteMeta.noteId); notePaths[note.noteId] = filePathPrefix + (noteMeta.dataFileName || noteMeta.dirFileName); if (noteMeta.dataFileName) { const content = prepareContent(noteMeta.title, note.getContent(), noteMeta); archive.append(content, { name: filePathPrefix + noteMeta.dataFileName, date: dateUtils.parseDateTime(note.utcDateModified) }); } taskContext.increaseProgressCount(); for (const ancillaryMeta of noteMeta.ancillaries || []) { const noteAncillary = note.getNoteAncillaryByName(ancillaryMeta.name); const content = noteAncillary.getContent(); archive.append(content, { name: filePathPrefix + ancillaryMeta.dataFileName, date: dateUtils.parseDateTime(note.utcDateModified) }); } if (noteMeta.children && noteMeta.children.length > 0) { const directoryPath = filePathPrefix + noteMeta.dirFileName; // create directory archive.append('', { name: `${directoryPath}/`, date: dateUtils.parseDateTime(note.utcDateModified) }); for (const childMeta of noteMeta.children) { saveNote(childMeta, `${directoryPath}/`); } } } function saveNavigation(rootMeta, navigationMeta) { function saveNavigationInner(meta) { let html = '