diff --git a/src/public/javascripts/dialogs/export_subtree.js b/src/public/javascripts/dialogs/export_subtree.js index 2a5d18b93..3d6058fa8 100644 --- a/src/public/javascripts/dialogs/export_subtree.js +++ b/src/public/javascripts/dialogs/export_subtree.js @@ -6,7 +6,6 @@ import exportService from "../services/export.js"; const $dialog = $("#export-subtree-dialog"); const $form = $("#export-subtree-form"); const $noteTitle = $dialog.find(".note-title"); -const $exportFormat = $dialog.find("input[name='export-format']:checked"); async function showDialog() { glob.activeDialog = $dialog; @@ -20,7 +19,7 @@ async function showDialog() { } $form.submit(() => { - const exportFormat = $exportFormat.val(); + const exportFormat = $dialog.find("input[name='export-format']:checked").val(); const currentNode = treeService.getCurrentNode(); diff --git a/src/routes/api/export.js b/src/routes/api/export.js index 460c9a003..2336584a4 100644 --- a/src/routes/api/export.js +++ b/src/routes/api/export.js @@ -1,270 +1,34 @@ "use strict"; -const html = require('html'); -const tar = require('tar-stream'); -const sanitize = require("sanitize-filename"); +const nativeTarExportService = require('../../services/export/native_tar'); +const markdownTarExportService = require('../../services/export/markdown_tar'); +const markdownSingleExportService = require('../../services/export/markdown_single'); +const opmlExportService = require('../../services/export/opml'); const repository = require("../../services/repository"); -const utils = require('../../services/utils'); -const TurndownService = require('turndown'); async function exportNote(req, res) { // entityId maybe either noteId or branchId depending on format const entityId = req.params.entityId; const format = req.params.format; - if (format === 'tar') { - await exportToTar(await repository.getBranch(entityId), res); + if (format === 'native-tar') { + await nativeTarExportService.exportToTar(await repository.getBranch(entityId), res); } - else if (format === 'opml') { - await exportToOpml(await repository.getBranch(entityId), res); - } - else if (format === 'markdown') { - await exportToMarkdown(await repository.getBranch(entityId), res); + else if (format === 'markdown-tar') { + await markdownTarExportService.exportToMarkdown(await repository.getBranch(entityId), res); } // export single note without subtree else if (format === 'markdown-single') { - await exportSingleMarkdown(await repository.getNote(entityId), res); + await markdownSingleExportService.exportSingleMarkdown(await repository.getNote(entityId), res); + } + else if (format === 'opml') { + await opmlExportService.exportToOpml(await repository.getBranch(entityId), res); } else { return [404, "Unrecognized export format " + format]; } } -function escapeXmlAttribute(text) { - return text.replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function prepareText(text) { - const newLines = text.replace(/(
]*>|
)/g, '\n')
- .replace(/ /g, ' '); // nbsp isn't in XML standard (only HTML)
-
- const stripped = utils.stripTags(newLines);
-
- const escaped = escapeXmlAttribute(stripped);
-
- return escaped.replace(/\n/g, '
');
-}
-
-async function exportToOpml(branch, res) {
- const note = await branch.getNote();
- const title = (branch.prefix ? (branch.prefix + ' - ') : '') + note.title;
- const sanitizedTitle = sanitize(title);
-
- async function exportNoteInner(branchId) {
- const branch = await repository.getBranch(branchId);
- const note = await branch.getNote();
- const title = (branch.prefix ? (branch.prefix + ' - ') : '') + note.title;
-
- const preparedTitle = prepareText(title);
- const preparedContent = prepareText(note.content);
-
- res.write(`
' + text.replace(/(?:\r\n|\r|\n)/g, '
') + '
'; -} - -async function importOutline(outline, parentNoteId) { - const {note} = await noteService.createNote(parentNoteId, outline.$.title, toHtml(outline.$.text)); - - for (const childOutline of (outline.outline || [])) { - await importOutline(childOutline, note.noteId); - } - - return note; -} - -async function importOpml(file, parentNote) { - const xml = await new Promise(function(resolve, reject) - { - parseString(file.buffer, function (err, result) { - if (err) { - reject(err); - } - else { - resolve(result); - } - }); - }); - - if (xml.opml.$.version !== '1.0' && xml.opml.$.version !== '1.1') { - return [400, 'Unsupported OPML version ' + xml.opml.$.version + ', 1.0 or 1.1 expected instead.']; - } - - const outlines = xml.opml.body[0].outline || []; - let returnNote = null; - - for (const outline of outlines) { - const note = await importOutline(outline, parentNote.noteId); - - // first created note will be activated after import - returnNote = returnNote || note; - } - - return returnNote; -} - -/** - * Complication of this export is the need to balance two needs: - * - - */ -async function importTar(file, parentNote) { - const files = await parseImportFile(file); - - const ctx = { - // maps from original noteId (in tar file) to newly generated noteId - noteIdMap: {}, - // new noteIds of notes which were actually created (not just referenced) - createdNoteIds: [], - attributes: [], - links: [], - reader: new commonmark.Parser(), - writer: new commonmark.HtmlRenderer() - }; - - ctx.getNewNoteId = function(origNoteId) { - // in case the original noteId is empty. This probably shouldn't happen, but still good to have this precaution - if (!origNoteId.trim()) { - return ""; - } - - if (!ctx.noteIdMap[origNoteId]) { - ctx.noteIdMap[origNoteId] = utils.newEntityId(); - } - - return ctx.noteIdMap[origNoteId]; - }; - - const note = await importNotes(ctx, files, parentNote.noteId); - - // we save attributes and links after importing notes because we need to check that target noteIds - // have been really created (relation/links with targets outside of the export are not created) - - for (const attr of ctx.attributes) { - if (attr.type === 'relation') { - attr.value = ctx.getNewNoteId(attr.value); - - if (!ctx.createdNoteIds.includes(attr.value)) { - // relation targets note outside of the export - continue; - } - } - - await new Attribute(attr).save(); - } - - for (const link of ctx.links) { - link.targetNoteId = ctx.getNewNoteId(link.targetNoteId); - - if (!ctx.createdNoteIds.includes(link.targetNoteId)) { - // link targets note outside of the export - continue; - } - - await new Link(link).save(); - } - - return note; -} - -function getFileName(name) { - let key; - - if (name.endsWith(".dat")) { - key = "data"; - name = name.substr(0, name.length - 4); - } - else if (name.endsWith(".md")) { - key = "markdown"; - name = name.substr(0, name.length - 3); - } - else if (name.endsWith((".meta"))) { - key = "meta"; - name = name.substr(0, name.length - 5); - } - else { - log.error("Unknown file type in import: " + name); - } - - return {name, key}; -} - -async function parseImportFile(file) { - const fileMap = {}; - const files = []; - - const extract = tar.extract(); - - extract.on('entry', function(header, stream, next) { - let name, key; - - if (header.type === 'file') { - ({name, key} = getFileName(header.name)); - } - else if (header.type === 'directory') { - // directory entries in tar often end with directory separator - name = (header.name.endsWith("/") || header.name.endsWith("\\")) ? header.name.substr(0, header.name.length - 1) : header.name; - key = 'directory'; - } - else { - log.error("Unrecognized tar entry: " + JSON.stringify(header)); - return; - } - - let file = fileMap[name]; - - if (!file) { - file = fileMap[name] = { - name: path.basename(name), - children: [] - }; - - let parentFileName = path.dirname(header.name); - - if (parentFileName && parentFileName !== '.') { - fileMap[parentFileName].children.push(file); - } - else { - files.push(file); - } - } - - const chunks = []; - - stream.on("data", function (chunk) { - chunks.push(chunk); - }); - - // header is the tar header - // stream is the content body (might be an empty stream) - // call next when you are done with this entry - - stream.on('end', function() { - file[key] = Buffer.concat(chunks); - - if (key === "meta") { - file[key] = JSON.parse(file[key].toString("UTF-8")); - } - - next(); // ready for next entry - }); - - stream.resume(); // just auto drain the stream - }); - - return new Promise(resolve => { - extract.on('finish', function() { - resolve(files); - }); - - const bufferStream = new stream.PassThrough(); - bufferStream.end(file.buffer); - - bufferStream.pipe(extract); - }); -} - -async function importNotes(ctx, files, parentNoteId) { - let returnNote = null; - - for (const file of files) { - let note; - - if (!file.meta) { - let content = ''; - - if (file.data) { - content = file.data.toString("UTF-8"); - } - else if (file.markdown) { - const parsed = ctx.reader.parse(file.markdown.toString("UTF-8")); - content = ctx.writer.render(parsed); - } - - note = (await noteService.createNote(parentNoteId, file.name, content, { - type: 'text', - mime: 'text/html' - })).note; - } - else { - if (file.meta.version !== 1) { - throw new Error("Can't read meta data version " + file.meta.version); - } - - if (file.meta.clone) { - await new Branch({ - parentNoteId: parentNoteId, - noteId: ctx.getNewNoteId(file.meta.noteId), - prefix: file.meta.prefix, - isExpanded: !!file.meta.isExpanded - }).save(); - - return; - } - - if (file.meta.type !== 'file' && file.meta.type !== 'image') { - file.data = file.data.toString("UTF-8"); - - // this will replace all internal links ( and]*>|
)/g, '\n')
+ .replace(/ /g, ' '); // nbsp isn't in XML standard (only HTML)
+
+ const stripped = utils.stripTags(newLines);
+
+ const escaped = escapeXmlAttribute(stripped);
+
+ return escaped.replace(/\n/g, '
');
+}
+
+function escapeXmlAttribute(text) {
+ return text.replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+module.exports = {
+ exportToOpml
+};
\ No newline at end of file
diff --git a/src/services/import/markdown.js b/src/services/import/markdown.js
new file mode 100644
index 000000000..8ed911baf
--- /dev/null
+++ b/src/services/import/markdown.js
@@ -0,0 +1,30 @@
+"use strict";
+
+// note that this is for import of single markdown file only - for archive/structure of markdown files
+// see tar export/import
+
+const noteService = require('../../services/notes');
+const commonmark = require('commonmark');
+
+async function importMarkdown(file, parentNote) {
+ const markdownContent = file.buffer.toString("UTF-8");
+
+ const reader = new commonmark.Parser();
+ const writer = new commonmark.HtmlRenderer();
+
+ const parsed = reader.parse(markdownContent);
+ const htmlContent = writer.render(parsed);
+
+ const title = file.originalname.substr(0, file.originalname.length - 3); // strip .md extension
+
+ const {note} = await noteService.createNote(parentNote.noteId, title, htmlContent, {
+ type: 'text',
+ mime: 'text/html'
+ });
+
+ return note;
+}
+
+module.exports = {
+ importMarkdown
+};
\ No newline at end of file
diff --git a/src/services/import/opml.js b/src/services/import/opml.js
new file mode 100644
index 000000000..4bc19fdbe
--- /dev/null
+++ b/src/services/import/opml.js
@@ -0,0 +1,56 @@
+"use strict";
+
+const noteService = require('../../services/notes');
+const parseString = require('xml2js').parseString;
+
+async function importOpml(file, parentNote) {
+ const xml = await new Promise(function(resolve, reject)
+ {
+ parseString(file.buffer, function (err, result) {
+ if (err) {
+ reject(err);
+ }
+ else {
+ resolve(result);
+ }
+ });
+ });
+
+ if (xml.opml.$.version !== '1.0' && xml.opml.$.version !== '1.1') {
+ return [400, 'Unsupported OPML version ' + xml.opml.$.version + ', 1.0 or 1.1 expected instead.'];
+ }
+
+ const outlines = xml.opml.body[0].outline || [];
+ let returnNote = null;
+
+ for (const outline of outlines) {
+ const note = await importOutline(outline, parentNote.noteId);
+
+ // first created note will be activated after import
+ returnNote = returnNote || note;
+ }
+
+ return returnNote;
+}
+
+function toHtml(text) {
+ if (!text) {
+ return '';
+ }
+
+ return '
' + text.replace(/(?:\r\n|\r|\n)/g, '
') + '
'; +} + +async function importOutline(outline, parentNoteId) { + const {note} = await noteService.createNote(parentNoteId, outline.$.title, toHtml(outline.$.text)); + + for (const childOutline of (outline.outline || [])) { + await importOutline(childOutline, note.noteId); + } + + return note; +} + +module.exports = { + importOpml +}; \ No newline at end of file diff --git a/src/services/import/tar.js b/src/services/import/tar.js new file mode 100644 index 000000000..dfbd717d8 --- /dev/null +++ b/src/services/import/tar.js @@ -0,0 +1,265 @@ +"use strict"; + +const Attribute = require('../../entities/attribute'); +const Link = require('../../entities/link'); +const log = require('../../services/log'); +const utils = require('../../services/utils'); +const noteService = require('../../services/notes'); +const Branch = require('../../entities/branch'); +const tar = require('tar-stream'); +const stream = require('stream'); +const path = require('path'); +const commonmark = require('commonmark'); + +/** + * Complication of this export is the need to balance two needs: + * - + */ +async function importTar(file, parentNote) { + const files = await parseImportFile(file); + + const ctx = { + // maps from original noteId (in tar file) to newly generated noteId + noteIdMap: {}, + // new noteIds of notes which were actually created (not just referenced) + createdNoteIds: [], + attributes: [], + links: [], + reader: new commonmark.Parser(), + writer: new commonmark.HtmlRenderer() + }; + + ctx.getNewNoteId = function(origNoteId) { + // in case the original noteId is empty. This probably shouldn't happen, but still good to have this precaution + if (!origNoteId.trim()) { + return ""; + } + + if (!ctx.noteIdMap[origNoteId]) { + ctx.noteIdMap[origNoteId] = utils.newEntityId(); + } + + return ctx.noteIdMap[origNoteId]; + }; + + const note = await importNotes(ctx, files, parentNote.noteId); + + // we save attributes and links after importing notes because we need to check that target noteIds + // have been really created (relation/links with targets outside of the export are not created) + + for (const attr of ctx.attributes) { + if (attr.type === 'relation') { + attr.value = ctx.getNewNoteId(attr.value); + + if (!ctx.createdNoteIds.includes(attr.value)) { + // relation targets note outside of the export + continue; + } + } + + await new Attribute(attr).save(); + } + + for (const link of ctx.links) { + link.targetNoteId = ctx.getNewNoteId(link.targetNoteId); + + if (!ctx.createdNoteIds.includes(link.targetNoteId)) { + // link targets note outside of the export + continue; + } + + await new Link(link).save(); + } + + return note; +} + +function getFileName(name) { + let key; + + if (name.endsWith(".dat")) { + key = "data"; + name = name.substr(0, name.length - 4); + } + else if (name.endsWith(".md")) { + key = "markdown"; + name = name.substr(0, name.length - 3); + } + else if (name.endsWith((".meta"))) { + key = "meta"; + name = name.substr(0, name.length - 5); + } + else { + log.error("Unknown file type in import: " + name); + } + + return {name, key}; +} + +async function parseImportFile(file) { + const fileMap = {}; + const files = []; + + const extract = tar.extract(); + + extract.on('entry', function(header, stream, next) { + let name, key; + + if (header.type === 'file') { + ({name, key} = getFileName(header.name)); + } + else if (header.type === 'directory') { + // directory entries in tar often end with directory separator + name = (header.name.endsWith("/") || header.name.endsWith("\\")) ? header.name.substr(0, header.name.length - 1) : header.name; + key = 'directory'; + } + else { + log.error("Unrecognized tar entry: " + JSON.stringify(header)); + return; + } + + let file = fileMap[name]; + + if (!file) { + file = fileMap[name] = { + name: path.basename(name), + children: [] + }; + + let parentFileName = path.dirname(header.name); + + if (parentFileName && parentFileName !== '.') { + fileMap[parentFileName].children.push(file); + } + else { + files.push(file); + } + } + + const chunks = []; + + stream.on("data", function (chunk) { + chunks.push(chunk); + }); + + // header is the tar header + // stream is the content body (might be an empty stream) + // call next when you are done with this entry + + stream.on('end', function() { + file[key] = Buffer.concat(chunks); + + if (key === "meta") { + file[key] = JSON.parse(file[key].toString("UTF-8")); + } + + next(); // ready for next entry + }); + + stream.resume(); // just auto drain the stream + }); + + return new Promise(resolve => { + extract.on('finish', function() { + resolve(files); + }); + + const bufferStream = new stream.PassThrough(); + bufferStream.end(file.buffer); + + bufferStream.pipe(extract); + }); +} + +async function importNotes(ctx, files, parentNoteId) { + let returnNote = null; + + for (const file of files) { + let note; + + if (!file.meta) { + let content = ''; + + if (file.data) { + content = file.data.toString("UTF-8"); + } + else if (file.markdown) { + const parsed = ctx.reader.parse(file.markdown.toString("UTF-8")); + content = ctx.writer.render(parsed); + } + + note = (await noteService.createNote(parentNoteId, file.name, content, { + type: 'text', + mime: 'text/html' + })).note; + } + else { + if (file.meta.version !== 1) { + throw new Error("Can't read meta data version " + file.meta.version); + } + + if (file.meta.clone) { + await new Branch({ + parentNoteId: parentNoteId, + noteId: ctx.getNewNoteId(file.meta.noteId), + prefix: file.meta.prefix, + isExpanded: !!file.meta.isExpanded + }).save(); + + return; + } + + if (file.meta.type !== 'file' && file.meta.type !== 'image') { + file.data = file.data.toString("UTF-8"); + + // this will replace all internal links ( and