mirror of
https://github.com/zadam/trilium.git
synced 2025-11-01 19:05:59 +01:00
Compare commits
26 Commits
v0.24.4-be
...
v0.24.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a616739805 | ||
|
|
bea28de6a0 | ||
|
|
4e198ca2f0 | ||
|
|
2fbd16a0e3 | ||
|
|
76fc49f037 | ||
|
|
139c99440f | ||
|
|
137b9dfa0b | ||
|
|
5f0fdd15eb | ||
|
|
61e1427b83 | ||
|
|
b3aa0ba47c | ||
|
|
56e2b44c25 | ||
|
|
4d5a17583f | ||
|
|
71eda5aa3d | ||
|
|
0711ea8dc8 | ||
|
|
be206872d1 | ||
|
|
fcf3fe8dcd | ||
|
|
62dbd4062a | ||
|
|
196e8b4380 | ||
|
|
551e1255ff | ||
|
|
e09b61d1ac | ||
|
|
ee23bcc783 | ||
|
|
3e351bd8d3 | ||
|
|
0d3bc22d73 | ||
|
|
d82898421e | ||
|
|
1db2f0c2c5 | ||
|
|
6cd8a2203e |
BIN
db/demo.tar
BIN
db/demo.tar
Binary file not shown.
@@ -0,0 +1 @@
|
||||
UPDATE attributes SET name = 'archived' where name = 'hideInAutocomplete';
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trilium",
|
||||
"version": "0.24.3-beta",
|
||||
"version": "0.24.4-beta",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -6417,11 +6417,18 @@
|
||||
"integrity": "sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw=="
|
||||
},
|
||||
"mime-types": {
|
||||
"version": "2.1.20",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.20.tgz",
|
||||
"integrity": "sha512-HrkrPaP9vGuWbLK1B1FfgAkbqNjIuy4eHlIYnFi7kamZyLLrGlo2mpcx0bBmNpKqBtYtAfGbodDddIgddSJC2A==",
|
||||
"version": "2.1.21",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.21.tgz",
|
||||
"integrity": "sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg==",
|
||||
"requires": {
|
||||
"mime-db": "~1.36.0"
|
||||
"mime-db": "~1.37.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"mime-db": {
|
||||
"version": "1.37.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.37.0.tgz",
|
||||
"integrity": "sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"mimic-fn": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "trilium",
|
||||
"productName": "Trilium Notes",
|
||||
"description": "Trilium Notes",
|
||||
"version": "0.24.4-beta",
|
||||
"version": "0.24.5",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "electron.js",
|
||||
"bin": {
|
||||
@@ -45,6 +45,7 @@
|
||||
"imagemin-pngquant": "6.0.0",
|
||||
"ini": "1.3.5",
|
||||
"jimp": "0.5.6",
|
||||
"mime-types": "^2.1.21",
|
||||
"moment": "2.22.2",
|
||||
"multer": "1.4.1",
|
||||
"open": "0.0.5",
|
||||
|
||||
@@ -4,6 +4,7 @@ const Entity = require('./entity');
|
||||
const Attribute = require('./attribute');
|
||||
const protectedSessionService = require('../services/protected_session');
|
||||
const repository = require('../services/repository');
|
||||
const sql = require('../services/sql');
|
||||
const dateUtils = require('../services/date_utils');
|
||||
|
||||
const LABEL = 'label';
|
||||
@@ -433,14 +434,32 @@ class Note extends Entity {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds child notes with given attribute name and value. Only own attributes are considered, not inherited ones
|
||||
* @return {Promise<string[]>} return list of all descendant noteIds of this note. Returning just noteIds because number of notes can be huge. Includes also this note's noteId
|
||||
*/
|
||||
async getDescendantNoteIds() {
|
||||
return await sql.getColumn(`
|
||||
WITH RECURSIVE
|
||||
tree(noteId) AS (
|
||||
SELECT ?
|
||||
UNION
|
||||
SELECT branches.noteId FROM branches
|
||||
JOIN tree ON branches.parentNoteId = tree.noteId
|
||||
JOIN notes ON notes.noteId = branches.noteId
|
||||
WHERE notes.isDeleted = 0
|
||||
AND branches.isDeleted = 0
|
||||
)
|
||||
SELECT noteId FROM tree`, [this.noteId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds descendant notes with given attribute name and value. Only own attributes are considered, not inherited ones
|
||||
*
|
||||
* @param {string} type - attribute type (label, relation, etc.)
|
||||
* @param {string} name - attribute name
|
||||
* @param {string} [value] - attribute value
|
||||
* @returns {Promise<Note[]>}
|
||||
*/
|
||||
async findChildNotesWithAttribute(type, name, value) {
|
||||
async getDescendantNotesWithAttribute(type, name, value) {
|
||||
const params = [this.noteId, name];
|
||||
let valueCondition = "";
|
||||
|
||||
@@ -472,22 +491,22 @@ class Note extends Entity {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds notes with given label name and value. Only own labels are considered, not inherited ones
|
||||
* Finds descendant notes with given label name and value. Only own labels are considered, not inherited ones
|
||||
*
|
||||
* @param {string} name - label name
|
||||
* @param {string} [value] - label value
|
||||
* @returns {Promise<Note[]>}
|
||||
*/
|
||||
async findChildNotesWithLabel(name, value) { return await this.findChildNotesWithAttribute(LABEL, name, value); }
|
||||
async getDescendantNotesWithLabel(name, value) { return await this.getDescendantNotesWithAttribute(LABEL, name, value); }
|
||||
|
||||
/**
|
||||
* Finds notes with given relation name and value. Only own relations are considered, not inherited ones
|
||||
* Finds descendant notes with given relation name and value. Only own relations are considered, not inherited ones
|
||||
*
|
||||
* @param {string} name - relation name
|
||||
* @param {string} [value] - relation value
|
||||
* @returns {Promise<Note[]>}
|
||||
*/
|
||||
async findChildNotesWithRelation(name, value) { return await this.findChildNotesWithAttribute(RELATION, name, value); }
|
||||
async getDescendantNotesWithRelation(name, value) { return await this.getDescendantNotesWithAttribute(RELATION, name, value); }
|
||||
|
||||
/**
|
||||
* Returns note revisions of this note.
|
||||
|
||||
77
src/public/javascripts/dialogs/export.js
Normal file
77
src/public/javascripts/dialogs/export.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import treeService from '../services/tree.js';
|
||||
import treeUtils from "../services/tree_utils.js";
|
||||
import exportService from "../services/export.js";
|
||||
|
||||
const $dialog = $("#export-dialog");
|
||||
const $form = $("#export-form");
|
||||
const $noteTitle = $dialog.find(".note-title");
|
||||
const $subtreeFormats = $("#export-subtree-formats");
|
||||
const $singleFormats = $("#export-single-formats");
|
||||
const $subtreeType = $("#export-type-subtree");
|
||||
const $singleType = $("#export-type-single");
|
||||
|
||||
async function showDialog(defaultType) {
|
||||
if (defaultType === 'subtree') {
|
||||
$subtreeType.prop("checked", true).change();
|
||||
}
|
||||
else if (defaultType === 'single') {
|
||||
$singleType.prop("checked", true).change();
|
||||
}
|
||||
else {
|
||||
throw new Error("Unrecognized type " + defaultType);
|
||||
}
|
||||
|
||||
glob.activeDialog = $dialog;
|
||||
|
||||
$dialog.modal();
|
||||
|
||||
const currentNode = treeService.getCurrentNode();
|
||||
const noteTitle = await treeUtils.getNoteTitle(currentNode.data.noteId);
|
||||
|
||||
$noteTitle.html(noteTitle);
|
||||
}
|
||||
|
||||
$form.submit(() => {
|
||||
const exportType = $dialog.find("input[name='export-type']:checked").val();
|
||||
|
||||
if (!exportType) {
|
||||
// this shouldn't happen as we always choose default export type
|
||||
alert("Choose export type first please");
|
||||
return;
|
||||
}
|
||||
|
||||
const exportFormat = exportType === 'subtree'
|
||||
? $("input[name=export-subtree-format]:checked").val()
|
||||
: $("input[name=export-single-format]:checked").val();
|
||||
|
||||
const currentNode = treeService.getCurrentNode();
|
||||
|
||||
exportService.exportBranch(currentNode.data.branchId, exportType, exportFormat);
|
||||
|
||||
$dialog.modal('hide');
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$('input[name=export-type]').change(function () {
|
||||
if (this.value === 'subtree') {
|
||||
if ($("input[name=export-subtree-format]:checked").length === 0) {
|
||||
$("input[name=export-subtree-format]:first").prop("checked", true);
|
||||
}
|
||||
|
||||
$subtreeFormats.slideDown();
|
||||
$singleFormats.slideUp();
|
||||
}
|
||||
else {
|
||||
if ($("input[name=export-single-format]:checked").length === 0) {
|
||||
$("input[name=export-single-format]:first").prop("checked", true);
|
||||
}
|
||||
|
||||
$subtreeFormats.slideUp();
|
||||
$singleFormats.slideDown();
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
showDialog
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import treeService from '../services/tree.js';
|
||||
import server from '../services/server.js';
|
||||
import treeUtils from "../services/tree_utils.js";
|
||||
import exportService from "../services/export.js";
|
||||
|
||||
const $dialog = $("#export-subtree-dialog");
|
||||
const $form = $("#export-subtree-form");
|
||||
const $noteTitle = $dialog.find(".note-title");
|
||||
|
||||
async function showDialog() {
|
||||
glob.activeDialog = $dialog;
|
||||
|
||||
$dialog.modal();
|
||||
|
||||
const currentNode = treeService.getCurrentNode();
|
||||
const noteTitle = await treeUtils.getNoteTitle(currentNode.data.noteId);
|
||||
|
||||
$noteTitle.html(noteTitle);
|
||||
}
|
||||
|
||||
$form.submit(() => {
|
||||
const exportFormat = $dialog.find("input[name='export-format']:checked").val();
|
||||
|
||||
const currentNode = treeService.getCurrentNode();
|
||||
|
||||
exportService.exportSubtree(currentNode.data.branchId, exportFormat);
|
||||
|
||||
$dialog.modal('hide');
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
export default {
|
||||
showDialog
|
||||
};
|
||||
@@ -14,7 +14,7 @@ class Branch {
|
||||
/** @param {string} */
|
||||
this.prefix = row.prefix;
|
||||
/** @param {boolean} */
|
||||
this.isExpanded = row.isExpanded;
|
||||
this.isExpanded = !!row.isExpanded;
|
||||
}
|
||||
|
||||
/** @returns {NoteShort} */
|
||||
|
||||
@@ -12,7 +12,8 @@ function initAttributeNameAutocomplete({ $el, attributeType, open }) {
|
||||
hint: false,
|
||||
autoselect: true,
|
||||
openOnFocus: true,
|
||||
minLength: 0
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
}, [{
|
||||
displayKey: 'name',
|
||||
// disabling cache is important here because otherwise cache can stay intact when switching between attribute type which will lead to autocomplete displaying attribute names for incorrect attribute type
|
||||
@@ -59,7 +60,8 @@ async function initLabelValueAutocomplete({ $el, open }) {
|
||||
hint: false,
|
||||
autoselect: true,
|
||||
openOnFocus: true,
|
||||
minLength: 0
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
}, [{
|
||||
displayKey: 'value',
|
||||
source: function (term, cb) {
|
||||
|
||||
@@ -146,7 +146,8 @@ async function createPromotedAttributeRow(definitionAttr, valueAttr) {
|
||||
hint: false,
|
||||
autoselect: true,
|
||||
openOnFocus: true,
|
||||
minLength: 0
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
}, [{
|
||||
displayKey: 'value',
|
||||
source: function (term, cb) {
|
||||
|
||||
5
src/public/javascripts/services/bootstrap.js
vendored
5
src/public/javascripts/services/bootstrap.js
vendored
@@ -7,6 +7,7 @@ import recentChangesDialog from '../dialogs/recent_changes.js';
|
||||
import optionsDialog from '../dialogs/options.js';
|
||||
import sqlConsoleDialog from '../dialogs/sql_console.js';
|
||||
import markdownImportDialog from '../dialogs/markdown_import.js';
|
||||
import exportDialog from '../dialogs/export.js';
|
||||
|
||||
import cloning from './cloning.js';
|
||||
import contextMenu from './tree_context_menu.js';
|
||||
@@ -103,12 +104,12 @@ if (utils.isElectron()) {
|
||||
});
|
||||
}
|
||||
|
||||
$("#export-note-to-markdown-button").click(function () {
|
||||
$("#export-note-button").click(function () {
|
||||
if ($(this).hasClass("disabled")) {
|
||||
return;
|
||||
}
|
||||
|
||||
exportService.exportSubtree(noteDetailService.getCurrentNoteId(), 'markdown-single')
|
||||
exportDialog.showDialog('single');
|
||||
});
|
||||
|
||||
treeService.showTree();
|
||||
|
||||
@@ -43,8 +43,8 @@ function registerEntrypoints() {
|
||||
|
||||
$("#recent-changes-button").click(recentChangesDialog.showDialog);
|
||||
|
||||
$("#protected-session-on").click(protectedSessionService.enterProtectedSession);
|
||||
$("#protected-session-off").click(protectedSessionService.leaveProtectedSession);
|
||||
$("#enter-protected-session-button").click(protectedSessionService.enterProtectedSession);
|
||||
$("#leave-protected-session-button").click(protectedSessionService.leaveProtectedSession);
|
||||
|
||||
$("#toggle-search-button").click(searchNotesService.toggleSearch);
|
||||
utils.bindShortcut('ctrl+s', searchNotesService.toggleSearch);
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import treeService from './tree.js';
|
||||
import infoService from './info.js';
|
||||
import protectedSessionHolder from './protected_session_holder.js';
|
||||
import utils from './utils.js';
|
||||
import server from './server.js';
|
||||
|
||||
function exportSubtree(noteId, format) {
|
||||
const url = utils.getHost() + "/api/notes/" + noteId + "/export/" + format +
|
||||
"?protectedSessionId=" + encodeURIComponent(protectedSessionHolder.getProtectedSessionId());
|
||||
function exportBranch(branchId, type, format) {
|
||||
const url = utils.getHost() + `/api/notes/${branchId}/export/${type}/${format}?protectedSessionId=` + encodeURIComponent(protectedSessionHolder.getProtectedSessionId());
|
||||
|
||||
console.log(url);
|
||||
|
||||
utils.download(url);
|
||||
|
||||
infoService.showMessage("Export to file has been finished.");
|
||||
}
|
||||
|
||||
let importNoteId;
|
||||
@@ -47,6 +45,6 @@ $("#import-upload").change(async function() {
|
||||
});
|
||||
|
||||
export default {
|
||||
exportSubtree,
|
||||
exportBranch,
|
||||
importIntoNote
|
||||
};
|
||||
@@ -5,13 +5,9 @@ function showMessage(message) {
|
||||
console.debug(utils.now(), "message: ", message);
|
||||
|
||||
$.notify({
|
||||
// options
|
||||
icon: 'jam jam-check',
|
||||
message: message
|
||||
}, {
|
||||
// options
|
||||
type: 'success',
|
||||
delay: 3000
|
||||
});
|
||||
}, getNotifySettings('success', 3000));
|
||||
}
|
||||
|
||||
function showAndLogError(message, delay = 10000) {
|
||||
@@ -25,12 +21,26 @@ function showError(message, delay = 10000) {
|
||||
|
||||
$.notify({
|
||||
// options
|
||||
icon: 'jam jam-alert',
|
||||
message: message
|
||||
}, {
|
||||
// options
|
||||
type: 'danger',
|
||||
}, getNotifySettings('danger', delay));
|
||||
}
|
||||
|
||||
function getNotifySettings(type, delay) {
|
||||
return {
|
||||
element: 'body',
|
||||
type: type,
|
||||
z_index: 90000,
|
||||
placement: {
|
||||
from: "top",
|
||||
align: "center"
|
||||
},
|
||||
animate: {
|
||||
enter: 'animated fadeInDown',
|
||||
exit: 'animated fadeOutUp'
|
||||
},
|
||||
delay: delay
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function throwError(message) {
|
||||
|
||||
@@ -76,7 +76,8 @@ function initNoteAutocomplete($el, options) {
|
||||
hint: false,
|
||||
autoselect: true,
|
||||
openOnFocus: true,
|
||||
minLength: 0
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
}, [
|
||||
{
|
||||
source: autocompleteSource,
|
||||
|
||||
@@ -28,6 +28,7 @@ const $noteDetailWrapper = $("#note-detail-wrapper");
|
||||
const $noteIdDisplay = $("#note-id-display");
|
||||
const $childrenOverview = $("#children-overview");
|
||||
const $scriptArea = $("#note-detail-script-area");
|
||||
const $savedIndicator = $("#saved-indicator");
|
||||
|
||||
let currentNote = null;
|
||||
|
||||
@@ -78,6 +79,8 @@ function noteChanged() {
|
||||
}
|
||||
|
||||
isNoteChanged = true;
|
||||
|
||||
$savedIndicator.fadeOut();
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
@@ -120,15 +123,16 @@ async function saveNote() {
|
||||
protectedSessionHolder.touchProtectedSession();
|
||||
}
|
||||
|
||||
infoService.showMessage("Saved!");
|
||||
$savedIndicator.fadeIn();
|
||||
}
|
||||
|
||||
async function saveNoteIfChanged() {
|
||||
if (!isNoteChanged) {
|
||||
return;
|
||||
if (isNoteChanged) {
|
||||
await saveNote();
|
||||
}
|
||||
|
||||
await saveNote();
|
||||
// make sure indicator is visible in a case there was some race condition.
|
||||
$savedIndicator.fadeIn();
|
||||
}
|
||||
|
||||
function setNoteBackgroundIfProtected(note) {
|
||||
@@ -294,7 +298,7 @@ $(document).ready(() => {
|
||||
// this sends the request asynchronously and doesn't wait for result
|
||||
$(window).on('beforeunload', () => { saveNoteIfChanged(); }); // don't convert to short form, handler doesn't like returned promise
|
||||
|
||||
setInterval(saveNoteIfChanged, 5000);
|
||||
setInterval(saveNoteIfChanged, 3000);
|
||||
|
||||
export default {
|
||||
reload,
|
||||
|
||||
@@ -11,8 +11,8 @@ const $password = $("#protected-session-password");
|
||||
const $noteDetailWrapper = $("#note-detail-wrapper");
|
||||
const $protectButton = $("#protect-button");
|
||||
const $unprotectButton = $("#unprotect-button");
|
||||
const $protectedSessionOnButton = $("#protected-session-on");
|
||||
const $protectedSessionOffButton = $("#protected-session-off");
|
||||
const $enterProtectedSessionButton = $("#enter-protected-session-button");
|
||||
const $leaveProtectedSessionButton = $("#leave-protected-session-button");
|
||||
|
||||
let protectedSessionDeferred = null;
|
||||
|
||||
@@ -57,7 +57,7 @@ async function setupProtectedSession() {
|
||||
const response = await enterProtectedSessionOnServer(password);
|
||||
|
||||
if (!response.success) {
|
||||
infoService.showError("Wrong password.");
|
||||
infoService.showError("Wrong password.", 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ async function setupProtectedSession() {
|
||||
protectedSessionDeferred.resolve(true);
|
||||
protectedSessionDeferred = null;
|
||||
|
||||
$protectedSessionOnButton.addClass('active');
|
||||
$protectedSessionOffButton.removeClass('active');
|
||||
$enterProtectedSessionButton.hide();
|
||||
$leaveProtectedSessionButton.show();
|
||||
}
|
||||
|
||||
infoService.showMessage("Protected session has been started.");
|
||||
|
||||
@@ -564,8 +564,6 @@ async function createNote(node, parentNoteId, target, isProtected, saveSelection
|
||||
|
||||
clearSelectedNodes(); // to unmark previously active node
|
||||
|
||||
infoService.showMessage("Created!");
|
||||
|
||||
return {note, branch};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import protectedSessionService from './protected_session.js';
|
||||
import treeChangesService from './branches.js';
|
||||
import treeUtils from './tree_utils.js';
|
||||
import branchPrefixDialog from '../dialogs/branch_prefix.js';
|
||||
import exportSubtreeDialog from '../dialogs/export_subtree.js';
|
||||
import exportDialog from '../dialogs/export.js';
|
||||
import infoService from "./info.js";
|
||||
import treeCache from "./tree_cache.js";
|
||||
import syncService from "./sync.js";
|
||||
@@ -93,7 +93,7 @@ const contextMenuItems = [
|
||||
{title: "Paste into <kbd>Ctrl+V</kbd>", cmd: "pasteInto", uiIcon: "clipboard"},
|
||||
{title: "Paste after", cmd: "pasteAfter", uiIcon: "clipboard"},
|
||||
{title: "----"},
|
||||
{title: "Export subtree", cmd: "exportSubtree", uiIcon: "arrow-up-right"},
|
||||
{title: "Export", cmd: "export", uiIcon: "arrow-up-right"},
|
||||
{title: "Import into note (tar, opml, md, enex)", cmd: "importIntoNote", uiIcon: "arrow-down-left"},
|
||||
{title: "----"},
|
||||
{title: "Collapse subtree <kbd>Alt+-</kbd>", cmd: "collapseSubtree", uiIcon: "align-justify"},
|
||||
@@ -127,7 +127,7 @@ async function getContextMenuItems(event) {
|
||||
enableItem("pasteAfter", clipboardIds.length > 0 && isNotRoot && parentNote.type !== 'search');
|
||||
enableItem("pasteInto", clipboardIds.length > 0 && note.type !== 'search');
|
||||
enableItem("importIntoNote", note.type !== 'search');
|
||||
enableItem("exportSubtree", note.type !== 'search');
|
||||
enableItem("export", note.type !== 'search');
|
||||
enableItem("editBranchPrefix", isNotRoot && parentNote.type !== 'search');
|
||||
|
||||
// Activate node on right-click
|
||||
@@ -179,8 +179,8 @@ function selectContextMenuItem(event, cmd) {
|
||||
else if (cmd === "delete") {
|
||||
treeChangesService.deleteNodes(treeService.getSelectedNodes(true));
|
||||
}
|
||||
else if (cmd === "exportSubtree") {
|
||||
exportSubtreeDialog.showDialog();
|
||||
else if (cmd === "export") {
|
||||
exportDialog.showDialog("subtree");
|
||||
}
|
||||
else if (cmd === "importIntoNote") {
|
||||
exportService.importIntoNote(node.data.noteId);
|
||||
|
||||
@@ -620,11 +620,11 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th
|
||||
}
|
||||
|
||||
.modalless {
|
||||
top:10%;
|
||||
left:50%;
|
||||
bottom:auto;
|
||||
right:auto;
|
||||
margin-left:-300px;
|
||||
top: 15%;
|
||||
left: 40%;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
margin-left: -300px;
|
||||
}
|
||||
|
||||
.multiplicity {
|
||||
@@ -634,4 +634,68 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th
|
||||
/* this is because bootstrap (?) sets code color to red for some reason */
|
||||
code {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.animated {
|
||||
animation-duration: 1s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -100%, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.fadeInDown {
|
||||
animation-name: fadeInDown;
|
||||
}
|
||||
|
||||
@keyframes fadeOutUp {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, -100%, 0);
|
||||
transform: translate3d(0, -100%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.fadeOutUp {
|
||||
animation-name: fadeOutUp;
|
||||
}
|
||||
|
||||
div[data-notify="container"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#saved-indicator {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 11px;
|
||||
font-size: x-large;
|
||||
color: #777;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#export-form .form-check {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
#export-form .format-choice {
|
||||
padding-left: 40px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#export-form .form-check-label {
|
||||
padding: 2px;
|
||||
}
|
||||
@@ -1,28 +1,22 @@
|
||||
"use strict";
|
||||
|
||||
const nativeTarExportService = require('../../services/export/native_tar');
|
||||
const markdownTarExportService = require('../../services/export/markdown_tar');
|
||||
const markdownSingleExportService = require('../../services/export/markdown_single');
|
||||
const tarExportService = require('../../services/export/tar');
|
||||
const singleExportService = require('../../services/export/single');
|
||||
const opmlExportService = require('../../services/export/opml');
|
||||
const repository = require("../../services/repository");
|
||||
|
||||
async function exportNote(req, res) {
|
||||
// entityId maybe either noteId or branchId depending on format
|
||||
const entityId = req.params.entityId;
|
||||
const format = req.params.format;
|
||||
async function exportBranch(req, res) {
|
||||
const {branchId, type, format} = req.params;
|
||||
const branch = await repository.getBranch(branchId);
|
||||
|
||||
if (format === 'native-tar') {
|
||||
await nativeTarExportService.exportToTar(await repository.getBranch(entityId), res);
|
||||
if (type === 'subtree' && (format === 'html' || format === 'markdown')) {
|
||||
await tarExportService.exportToTar(branch, format, 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 markdownSingleExportService.exportSingleMarkdown(await repository.getNote(entityId), res);
|
||||
else if (type === 'single') {
|
||||
await singleExportService.exportSingleNote(branch, format, res);
|
||||
}
|
||||
else if (format === 'opml') {
|
||||
await opmlExportService.exportToOpml(await repository.getBranch(entityId), res);
|
||||
await opmlExportService.exportToOpml(branch, res);
|
||||
}
|
||||
else {
|
||||
return [404, "Unrecognized export format " + format];
|
||||
@@ -30,5 +24,5 @@ async function exportNote(req, res) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
exportNote
|
||||
exportBranch
|
||||
};
|
||||
@@ -4,7 +4,8 @@ const repository = require('../../services/repository');
|
||||
const enexImportService = require('../../services/import/enex');
|
||||
const opmlImportService = require('../../services/import/opml');
|
||||
const tarImportService = require('../../services/import/tar');
|
||||
const markdownImportService = require('../../services/import/markdown');
|
||||
const singleImportService = require('../../services/import/single');
|
||||
const cls = require('../../services/cls');
|
||||
const path = require('path');
|
||||
|
||||
async function importToBranch(req) {
|
||||
@@ -23,6 +24,10 @@ async function importToBranch(req) {
|
||||
|
||||
const extension = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
// running all the event handlers on imported notes (and attributes) is slow
|
||||
// and may produce unintended consequences
|
||||
cls.disableEntityEvents();
|
||||
|
||||
if (extension === '.tar') {
|
||||
return await tarImportService.importTar(file.buffer, parentNote);
|
||||
}
|
||||
@@ -30,7 +35,10 @@ async function importToBranch(req) {
|
||||
return await opmlImportService.importOpml(file.buffer, parentNote);
|
||||
}
|
||||
else if (extension === '.md') {
|
||||
return await markdownImportService.importMarkdown(file, parentNote);
|
||||
return await singleImportService.importMarkdown(file, parentNote);
|
||||
}
|
||||
else if (extension === '.html' || extension === '.htm') {
|
||||
return await singleImportService.importHtml(file, parentNote);
|
||||
}
|
||||
else if (extension === '.enex') {
|
||||
return await enexImportService.importEnex(file, parentNote);
|
||||
|
||||
@@ -128,7 +128,7 @@ function register(app) {
|
||||
apiRoute(PUT, '/api/notes/:noteId/clone-to/:parentNoteId', cloningApiRoute.cloneNoteToParent);
|
||||
apiRoute(PUT, '/api/notes/:noteId/clone-after/:afterBranchId', cloningApiRoute.cloneNoteAfter);
|
||||
|
||||
route(GET, '/api/notes/:entityId/export/:format', [auth.checkApiAuthOrElectron], exportRoute.exportNote);
|
||||
route(GET, '/api/notes/:branchId/export/:type/:format', [auth.checkApiAuthOrElectron], exportRoute.exportBranch);
|
||||
route(POST, '/api/notes/:parentNoteId/import', [auth.checkApiAuthOrElectron, uploadMiddleware], importRoute.importToBranch, apiResultHandler);
|
||||
|
||||
route(POST, '/api/notes/:parentNoteId/upload', [auth.checkApiAuthOrElectron, uploadMiddleware],
|
||||
|
||||
@@ -6,6 +6,7 @@ const log = require("./log");
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const config = require('./config');
|
||||
const utils = require('./utils');
|
||||
|
||||
const template = `[Desktop Entry]
|
||||
Type=Application
|
||||
@@ -21,7 +22,9 @@ Terminal=false
|
||||
* We overwrite this file during every run as it might have been updated.
|
||||
*/
|
||||
function installLocalAppIcon() {
|
||||
if (["win32", "darwin"].includes(os.platform()) || (config.General && config.General.noDesktopIcon)) {
|
||||
if (!utils.isElectron()
|
||||
|| ["win32", "darwin"].includes(os.platform())
|
||||
|| (config.General && config.General.noDesktopIcon)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
const build = require('./build');
|
||||
const packageJson = require('../../package');
|
||||
|
||||
const APP_DB_VERSION = 119;
|
||||
const APP_DB_VERSION = 120;
|
||||
const SYNC_VERSION = 2;
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1 +1 @@
|
||||
module.exports = { buildDate:"2018-11-21T23:47:09+01:00", buildRevision: "3a064934598b70878f6da4c11c0ceb84ef18db57" };
|
||||
module.exports = { buildDate:"2018-11-27T15:34:15+01:00", buildRevision: "bea28de6a0a41bbb948551c43a4fbf787fc5ecb3" };
|
||||
|
||||
@@ -13,6 +13,14 @@ function getSourceId() {
|
||||
return namespace.get('sourceId');
|
||||
}
|
||||
|
||||
function disableEntityEvents() {
|
||||
namespace.set('disableEntityEvents', true);
|
||||
}
|
||||
|
||||
function isEntityEventsDisabled() {
|
||||
return !!namespace.get('disableEntityEvents');
|
||||
}
|
||||
|
||||
function reset() {
|
||||
clsHooked.reset();
|
||||
}
|
||||
@@ -22,5 +30,7 @@ module.exports = {
|
||||
wrap,
|
||||
namespace,
|
||||
getSourceId,
|
||||
disableEntityEvents,
|
||||
isEntityEventsDisabled,
|
||||
reset
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const sanitize = require("sanitize-filename");
|
||||
const TurndownService = require('turndown');
|
||||
|
||||
async function exportSingleMarkdown(note, res) {
|
||||
if (note.type !== 'text' && note.type !== 'code') {
|
||||
return [400, `Note type ${note.type} cannot be exported as single markdown file.`];
|
||||
}
|
||||
|
||||
let markdown;
|
||||
|
||||
if (note.type === 'code') {
|
||||
markdown = '```\n' + note.content + "\n```";
|
||||
}
|
||||
else if (note.type === 'text') {
|
||||
const turndownService = new TurndownService();
|
||||
markdown = turndownService.turndown(note.content);
|
||||
}
|
||||
|
||||
const name = sanitize(note.title);
|
||||
|
||||
res.setHeader('Content-Disposition', 'file; filename="' + name + '.md"');
|
||||
res.setHeader('Content-Type', 'text/markdown; charset=UTF-8');
|
||||
|
||||
res.send(markdown);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
exportSingleMarkdown
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const tar = require('tar-stream');
|
||||
const TurndownService = require('turndown');
|
||||
const sanitize = require("sanitize-filename");
|
||||
const markdownSingleExportService = require('../../services/export/markdown_single');
|
||||
|
||||
async function exportToMarkdown(branch, res) {
|
||||
const note = await branch.getNote();
|
||||
|
||||
if (!await note.hasChildren()) {
|
||||
await markdownSingleExportService.exportSingleMarkdown(note, res);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const turndownService = new TurndownService();
|
||||
const pack = tar.pack();
|
||||
const name = await exportNoteInner(note, '');
|
||||
|
||||
async function exportNoteInner(note, directory) {
|
||||
const childFileName = directory + sanitize(note.title);
|
||||
|
||||
if (await note.hasLabel('excludeFromExport')) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveNote(childFileName, note);
|
||||
|
||||
const childNotes = await note.getChildNotes();
|
||||
|
||||
if (childNotes.length > 0) {
|
||||
saveDirectory(childFileName);
|
||||
}
|
||||
|
||||
for (const childNote of childNotes) {
|
||||
await exportNoteInner(childNote, childFileName + "/");
|
||||
}
|
||||
|
||||
return childFileName;
|
||||
}
|
||||
|
||||
function saveTextNote(childFileName, note) {
|
||||
if (note.content.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let markdown;
|
||||
|
||||
if (note.type === 'code') {
|
||||
markdown = '```\n' + note.content + "\n```";
|
||||
}
|
||||
else if (note.type === 'text') {
|
||||
markdown = turndownService.turndown(note.content);
|
||||
}
|
||||
else {
|
||||
// other note types are not supported
|
||||
return;
|
||||
}
|
||||
|
||||
pack.entry({name: childFileName + ".md", size: markdown.length}, markdown);
|
||||
}
|
||||
|
||||
function saveFileNote(childFileName, note) {
|
||||
pack.entry({name: childFileName, size: note.content.length}, note.content);
|
||||
}
|
||||
|
||||
function saveNote(childFileName, note) {
|
||||
if (note.type === 'text' || note.type === 'code') {
|
||||
saveTextNote(childFileName, note);
|
||||
}
|
||||
else if (note.type === 'image' || note.type === 'file') {
|
||||
saveFileNote(childFileName, note);
|
||||
}
|
||||
}
|
||||
|
||||
function saveDirectory(childFileName) {
|
||||
pack.entry({name: childFileName, type: 'directory'});
|
||||
}
|
||||
|
||||
pack.finalize();
|
||||
|
||||
res.setHeader('Content-Disposition', 'file; filename="' + name + '.tar"');
|
||||
res.setHeader('Content-Type', 'application/tar');
|
||||
|
||||
pack.pipe(res);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
exportToMarkdown
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const html = require('html');
|
||||
const native_tar = require('tar-stream');
|
||||
const sanitize = require("sanitize-filename");
|
||||
|
||||
async function exportToTar(branch, res) {
|
||||
const pack = native_tar.pack();
|
||||
|
||||
const exportedNoteIds = [];
|
||||
const name = await exportNoteInner(branch, '');
|
||||
|
||||
async function exportNoteInner(branch, directory) {
|
||||
const note = await branch.getNote();
|
||||
const childFileName = directory + sanitize(note.title);
|
||||
|
||||
if (exportedNoteIds.includes(note.noteId)) {
|
||||
saveMetadataFile(childFileName, {
|
||||
version: 1,
|
||||
clone: true,
|
||||
noteId: note.noteId,
|
||||
prefix: branch.prefix
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
version: 1,
|
||||
clone: false,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
prefix: branch.prefix,
|
||||
isExpanded: branch.isExpanded,
|
||||
type: note.type,
|
||||
mime: note.mime,
|
||||
// we don't export dateCreated and dateModified of any entity since that would be a bit misleading
|
||||
attributes: (await note.getOwnedAttributes()).map(attribute => {
|
||||
return {
|
||||
type: attribute.type,
|
||||
name: attribute.name,
|
||||
value: attribute.value,
|
||||
isInheritable: attribute.isInheritable,
|
||||
position: attribute.position
|
||||
};
|
||||
}),
|
||||
links: (await note.getLinks()).map(link => {
|
||||
return {
|
||||
type: link.type,
|
||||
targetNoteId: link.targetNoteId
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if (await note.hasLabel('excludeFromExport')) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveMetadataFile(childFileName, metadata);
|
||||
saveDataFile(childFileName, note);
|
||||
|
||||
exportedNoteIds.push(note.noteId);
|
||||
|
||||
const childBranches = await note.getChildBranches();
|
||||
|
||||
if (childBranches.length > 0) {
|
||||
saveDirectory(childFileName);
|
||||
}
|
||||
|
||||
for (const childBranch of childBranches) {
|
||||
await exportNoteInner(childBranch, childFileName + "/");
|
||||
}
|
||||
|
||||
return childFileName;
|
||||
}
|
||||
|
||||
function saveDataFile(childFileName, note) {
|
||||
const content = note.type === 'text' ? html.prettyPrint(note.content, {indent_size: 2}) : note.content;
|
||||
|
||||
pack.entry({name: childFileName + ".dat", size: content.length}, content);
|
||||
}
|
||||
|
||||
function saveMetadataFile(childFileName, metadata) {
|
||||
const metadataJson = JSON.stringify(metadata, null, '\t');
|
||||
|
||||
pack.entry({name: childFileName + ".meta", size: metadataJson.length}, metadataJson);
|
||||
}
|
||||
|
||||
function saveDirectory(childFileName) {
|
||||
pack.entry({name: childFileName, type: 'directory'});
|
||||
}
|
||||
|
||||
pack.finalize();
|
||||
|
||||
res.setHeader('Content-Disposition', 'file; filename="' + name + '.tar"');
|
||||
res.setHeader('Content-Type', 'application/tar');
|
||||
|
||||
pack.pipe(res);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
exportToTar
|
||||
};
|
||||
57
src/services/export/single.js
Normal file
57
src/services/export/single.js
Normal file
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
|
||||
const sanitize = require("sanitize-filename");
|
||||
const TurndownService = require('turndown');
|
||||
const mimeTypes = require('mime-types');
|
||||
const html = require('html');
|
||||
|
||||
async function exportSingleNote(branch, format, res) {
|
||||
const note = await branch.getNote();
|
||||
|
||||
if (note.type === 'image' || note.type === 'file') {
|
||||
return [400, `Note type ${note.type} cannot be exported as single file.`];
|
||||
}
|
||||
|
||||
if (format !== 'html' && format !== 'markdown') {
|
||||
return [400, 'Unrecognized format ' + format];
|
||||
}
|
||||
|
||||
let payload, extension, mime;
|
||||
|
||||
if (note.type === 'text') {
|
||||
if (format === 'html') {
|
||||
payload = html.prettyPrint(note.content, {indent_size: 2});
|
||||
extension = 'html';
|
||||
mime = 'text/html';
|
||||
}
|
||||
else if (format === 'markdown') {
|
||||
const turndownService = new TurndownService();
|
||||
payload = turndownService.turndown(note.content);
|
||||
extension = 'md';
|
||||
mime = 'text/markdown'
|
||||
}
|
||||
}
|
||||
else if (note.type === 'code') {
|
||||
payload = note.content;
|
||||
extension = mimeTypes.extension(note.mime) || 'code';
|
||||
mime = note.mime;
|
||||
}
|
||||
else if (note.type === 'relation-map' || note.type === 'search') {
|
||||
payload = note.content;
|
||||
extension = 'json';
|
||||
mime = 'application/json';
|
||||
}
|
||||
|
||||
const name = sanitize(note.title);
|
||||
|
||||
console.log(name, extension, mime);
|
||||
|
||||
res.setHeader('Content-Disposition', `file; filename="${name}.${extension}"`);
|
||||
res.setHeader('Content-Type', mime + '; charset=UTF-8');
|
||||
|
||||
res.send(payload);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
exportSingleNote
|
||||
};
|
||||
229
src/services/export/tar.js
Normal file
229
src/services/export/tar.js
Normal file
@@ -0,0 +1,229 @@
|
||||
"use strict";
|
||||
|
||||
const html = require('html');
|
||||
const repository = require('../repository');
|
||||
const tar = require('tar-stream');
|
||||
const sanitize = require("sanitize-filename");
|
||||
const mimeTypes = require('mime-types');
|
||||
const TurndownService = require('turndown');
|
||||
const packageInfo = require('../../../package.json');
|
||||
|
||||
/**
|
||||
* @param format - 'html' or 'markdown'
|
||||
*/
|
||||
async function exportToTar(branch, format, res) {
|
||||
let turndownService = format === 'markdown' ? new TurndownService() : null;
|
||||
|
||||
const pack = tar.pack();
|
||||
|
||||
const noteIdToMeta = {};
|
||||
|
||||
function getUniqueFilename(existingFileNames, fileName) {
|
||||
const lcFileName = fileName.toLowerCase();
|
||||
|
||||
if (lcFileName in existingFileNames) {
|
||||
let index;
|
||||
let newName;
|
||||
|
||||
do {
|
||||
index = existingFileNames[lcFileName]++;
|
||||
|
||||
newName = lcFileName + "_" + index;
|
||||
}
|
||||
while (newName in existingFileNames);
|
||||
|
||||
return fileName + "_" + index;
|
||||
}
|
||||
else {
|
||||
existingFileNames[lcFileName] = 1;
|
||||
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
||||
function getDataFileName(note, baseFileName, existingFileNames) {
|
||||
let extension;
|
||||
|
||||
if (note.type === 'text' && format === 'markdown') {
|
||||
extension = 'md';
|
||||
}
|
||||
else if (note.mime === 'application/x-javascript') {
|
||||
extension = 'js';
|
||||
}
|
||||
else {
|
||||
extension = mimeTypes.extension(note.mime) || "dat";
|
||||
}
|
||||
|
||||
let fileName = baseFileName;
|
||||
|
||||
if (!fileName.toLowerCase().endsWith(extension)) {
|
||||
fileName += "." + extension;
|
||||
}
|
||||
|
||||
return getUniqueFilename(existingFileNames, fileName);
|
||||
}
|
||||
|
||||
async function getNote(branch, existingFileNames) {
|
||||
const note = await branch.getNote();
|
||||
|
||||
if (await note.hasLabel('excludeFromExport')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseFileName = branch.prefix ? (branch.prefix + ' - ' + note.title) : note.title;
|
||||
|
||||
if (note.noteId in noteIdToMeta) {
|
||||
const sanitizedFileName = sanitize(baseFileName + ".clone");
|
||||
const fileName = getUniqueFilename(existingFileNames, sanitizedFileName);
|
||||
|
||||
return {
|
||||
isClone: true,
|
||||
noteId: note.noteId,
|
||||
prefix: branch.prefix,
|
||||
dataFileName: fileName
|
||||
};
|
||||
}
|
||||
|
||||
const meta = {
|
||||
isClone: false,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
notePosition: branch.notePosition,
|
||||
prefix: branch.prefix,
|
||||
isExpanded: branch.isExpanded,
|
||||
type: note.type,
|
||||
mime: note.mime,
|
||||
// we don't export dateCreated and dateModified of any entity since that would be a bit misleading
|
||||
attributes: (await note.getOwnedAttributes()).map(attribute => {
|
||||
return {
|
||||
type: attribute.type,
|
||||
name: attribute.name,
|
||||
value: attribute.value,
|
||||
isInheritable: attribute.isInheritable,
|
||||
position: attribute.position
|
||||
};
|
||||
}),
|
||||
links: (await note.getLinks()).map(link => {
|
||||
return {
|
||||
type: link.type,
|
||||
targetNoteId: link.targetNoteId
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if (note.type === 'text') {
|
||||
meta.format = format;
|
||||
}
|
||||
|
||||
noteIdToMeta[note.noteId] = meta;
|
||||
|
||||
const childBranches = await note.getChildBranches();
|
||||
|
||||
// if it's a leaf then we'll export it even if it's empty
|
||||
if (note.content.length > 0 || childBranches.length === 0) {
|
||||
meta.dataFileName = getDataFileName(note, baseFileName, 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 = await getNote(childBranch, childExistingNames);
|
||||
|
||||
// can be undefined if export is disabled for this note
|
||||
if (note) {
|
||||
meta.children.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
function prepareContent(note, format) {
|
||||
if (format === 'html') {
|
||||
return html.prettyPrint(note.content, {indent_size: 2});
|
||||
}
|
||||
else if (format === 'markdown') {
|
||||
return turndownService.turndown(note.content);
|
||||
}
|
||||
else {
|
||||
return note.content;
|
||||
}
|
||||
}
|
||||
|
||||
// noteId => file path
|
||||
const notePaths = {};
|
||||
|
||||
async function saveNote(noteMeta, path) {
|
||||
if (noteMeta.isClone) {
|
||||
const content = "Note is present at " + notePaths[noteMeta.noteId];
|
||||
|
||||
pack.entry({name: path + noteMeta.dataFileName, size: content.length}, content);
|
||||
return;
|
||||
}
|
||||
|
||||
const note = await repository.getNote(noteMeta.noteId);
|
||||
|
||||
notePaths[note.noteId] = path + (noteMeta.dataFileName || noteMeta.dirFileName);
|
||||
|
||||
if (noteMeta.dataFileName) {
|
||||
const content = prepareContent(note, noteMeta.format);
|
||||
|
||||
pack.entry({name: path + noteMeta.dataFileName, size: content.length}, content);
|
||||
}
|
||||
|
||||
if (noteMeta.children && noteMeta.children.length > 0) {
|
||||
const directoryPath = path + noteMeta.dirFileName;
|
||||
|
||||
pack.entry({name: directoryPath, type: 'directory'});
|
||||
|
||||
for (const childMeta of noteMeta.children) {
|
||||
await saveNote(childMeta, directoryPath + '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metaFile = {
|
||||
formatVersion: 1,
|
||||
appVersion: packageInfo.version,
|
||||
files: [
|
||||
await getNote(branch, [])
|
||||
]
|
||||
};
|
||||
|
||||
for (const noteMeta of Object.values(noteIdToMeta)) {
|
||||
// filter out relations and links which are not inside this export
|
||||
noteMeta.attributes = noteMeta.attributes.filter(attr => attr.type !== 'relation' || attr.value in noteIdToMeta);
|
||||
noteMeta.links = noteMeta.links.filter(link => link.targetNoteId in noteIdToMeta);
|
||||
}
|
||||
|
||||
if (!metaFile.files[0]) { // corner case of disabled export for exported note
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
const metaFileJson = JSON.stringify(metaFile, null, '\t');
|
||||
|
||||
pack.entry({name: "!!!meta.json", size: metaFileJson.length}, metaFileJson);
|
||||
|
||||
await saveNote(metaFile.files[0], '');
|
||||
|
||||
pack.finalize();
|
||||
|
||||
const note = await branch.getNote();
|
||||
const tarFileName = sanitize((branch.prefix ? (branch.prefix + " - ") : "") + note.title);
|
||||
|
||||
res.setHeader('Content-Disposition', `file; filename="${tarFileName}.tar"`);
|
||||
res.setHeader('Content-Type', 'application/tar');
|
||||
|
||||
pack.pipe(res);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
exportToTar
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
"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
|
||||
};
|
||||
47
src/services/import/single.js
Normal file
47
src/services/import/single.js
Normal file
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
|
||||
const noteService = require('../../services/notes');
|
||||
const commonmark = require('commonmark');
|
||||
const path = require('path');
|
||||
|
||||
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 = getFileNameWithoutExtension(file.originalname);
|
||||
|
||||
const {note} = await noteService.createNote(parentNote.noteId, title, htmlContent, {
|
||||
type: 'text',
|
||||
mime: 'text/html'
|
||||
});
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
async function importHtml(file, parentNote) {
|
||||
const title = getFileNameWithoutExtension(file.originalname);
|
||||
const content = file.buffer.toString("UTF-8");
|
||||
|
||||
const {note} = await noteService.createNote(parentNote.noteId, title, content, {
|
||||
type: 'text',
|
||||
mime: 'text/html'
|
||||
});
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
function getFileNameWithoutExtension(filePath) {
|
||||
const extension = path.extname(filePath);
|
||||
|
||||
return filePath.substr(0, filePath.length - extension.length);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
importMarkdown,
|
||||
importHtml
|
||||
};
|
||||
@@ -2,30 +2,32 @@
|
||||
|
||||
const Attribute = require('../../entities/attribute');
|
||||
const Link = require('../../entities/link');
|
||||
const log = require('../../services/log');
|
||||
const utils = require('../../services/utils');
|
||||
const log = require('../../services/log');
|
||||
const repository = require('../../services/repository');
|
||||
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');
|
||||
const mimeTypes = require('mime-types');
|
||||
|
||||
async function importTar(fileBuffer, parentNote) {
|
||||
const files = await parseImportFile(fileBuffer);
|
||||
async function importTar(fileBuffer, importRootNote) {
|
||||
// maps from original noteId (in tar file) to newly generated noteId
|
||||
const noteIdMap = {};
|
||||
const attributes = [];
|
||||
const links = [];
|
||||
// path => noteId
|
||||
const createdPaths = { '/': importRootNote.noteId, '\\': importRootNote.noteId };
|
||||
const mdReader = new commonmark.Parser();
|
||||
const mdWriter = new commonmark.HtmlRenderer();
|
||||
let metaFile = null;
|
||||
let firstNote = null;
|
||||
|
||||
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()
|
||||
};
|
||||
const extract = tar.extract();
|
||||
|
||||
ctx.getNewNoteId = function(origNoteId) {
|
||||
function getNewNoteId(origNoteId) {
|
||||
// in case the original noteId is empty. This probably shouldn't happen, but still good to have this precaution
|
||||
if (!origNoteId.trim()) {
|
||||
return "";
|
||||
@@ -36,107 +38,274 @@ async function importTar(fileBuffer, parentNote) {
|
||||
return origNoteId;
|
||||
}
|
||||
|
||||
if (!ctx.noteIdMap[origNoteId]) {
|
||||
ctx.noteIdMap[origNoteId] = utils.newEntityId();
|
||||
if (!noteIdMap[origNoteId]) {
|
||||
noteIdMap[origNoteId] = utils.newEntityId();
|
||||
}
|
||||
|
||||
return ctx.noteIdMap[origNoteId];
|
||||
};
|
||||
return noteIdMap[origNoteId];
|
||||
}
|
||||
|
||||
function getMeta(filePath) {
|
||||
if (!metaFile) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const note = await importNotes(ctx, files, parentNote.noteId);
|
||||
const pathSegments = filePath.split(/[\/\\]/g);
|
||||
|
||||
// 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)
|
||||
let cursor = {
|
||||
isImportRoot: true,
|
||||
children: metaFile.files
|
||||
};
|
||||
|
||||
for (const attr of ctx.attributes) {
|
||||
if (attr.type === 'relation') {
|
||||
attr.value = ctx.getNewNoteId(attr.value);
|
||||
let parent;
|
||||
|
||||
if (!ctx.createdNoteIds.includes(attr.value)) {
|
||||
// relation targets note outside of the export
|
||||
continue;
|
||||
for (const segment of pathSegments) {
|
||||
if (!cursor || !cursor.children || cursor.children.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
parent = cursor;
|
||||
cursor = cursor.children.find(file => file.dataFileName === segment || file.dirFileName === segment);
|
||||
}
|
||||
|
||||
return {
|
||||
parentNoteMeta: parent,
|
||||
noteMeta: cursor
|
||||
};
|
||||
}
|
||||
|
||||
function getParentNoteId(filePath, parentNoteMeta) {
|
||||
let parentNoteId;
|
||||
|
||||
if (parentNoteMeta) {
|
||||
parentNoteId = parentNoteMeta.isImportRoot ? importRootNote.noteId : getNewNoteId(parentNoteMeta.noteId);
|
||||
}
|
||||
else {
|
||||
const parentPath = path.dirname(filePath);
|
||||
|
||||
if (parentPath === '.') {
|
||||
parentNoteId = importRootNote.noteId;
|
||||
}
|
||||
else if (parentPath in createdPaths) {
|
||||
parentNoteId = createdPaths[parentPath];
|
||||
}
|
||||
else {
|
||||
throw new Error(`Could not find existing path ${parentPath} for ${filePath}.`);
|
||||
}
|
||||
}
|
||||
|
||||
await new Attribute(attr).save();
|
||||
return parentNoteId;
|
||||
}
|
||||
|
||||
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(fileBuffer) {
|
||||
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';
|
||||
function getNoteTitle(filePath, noteMeta) {
|
||||
if (noteMeta) {
|
||||
return noteMeta.title;
|
||||
}
|
||||
else {
|
||||
log.error("Unrecognized tar entry: " + JSON.stringify(header));
|
||||
const basename = path.basename(filePath);
|
||||
|
||||
return getTextFileWithoutExtension(basename);
|
||||
}
|
||||
}
|
||||
|
||||
function getNoteId(noteMeta, filePath) {
|
||||
if (noteMeta) {
|
||||
return getNewNoteId(noteMeta.noteId);
|
||||
}
|
||||
else {
|
||||
const filePathNoExt = getTextFileWithoutExtension(filePath);
|
||||
|
||||
if (filePathNoExt in createdPaths) {
|
||||
return createdPaths[filePathNoExt];
|
||||
}
|
||||
else {
|
||||
return utils.newEntityId();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function detectFileTypeAndMime(filePath) {
|
||||
const mime = mimeTypes.lookup(filePath);
|
||||
let type = 'file';
|
||||
|
||||
if (mime) {
|
||||
if (mime === 'text/html' || mime === 'text/markdown') {
|
||||
type = 'text';
|
||||
}
|
||||
else if (mime.startsWith('image/')) {
|
||||
type = 'image';
|
||||
}
|
||||
}
|
||||
|
||||
return { type, mime };
|
||||
}
|
||||
|
||||
async function saveAttributesAndLinks(note, noteMeta) {
|
||||
if (!noteMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
let file = fileMap[name];
|
||||
for (const attr of noteMeta.attributes) {
|
||||
attr.noteId = note.noteId;
|
||||
|
||||
if (!file) {
|
||||
file = fileMap[name] = {
|
||||
name: path.basename(name),
|
||||
children: []
|
||||
};
|
||||
|
||||
let parentFileName = path.dirname(header.name);
|
||||
|
||||
if (parentFileName && parentFileName !== '.') {
|
||||
fileMap[parentFileName].children.push(file);
|
||||
if (attr.type === 'relation') {
|
||||
attr.value = getNewNoteId(attr.value);
|
||||
}
|
||||
else {
|
||||
files.push(file);
|
||||
|
||||
attributes.push(attr);
|
||||
}
|
||||
|
||||
for (const link of noteMeta.links) {
|
||||
link.noteId = note.noteId;
|
||||
link.targetNoteId = getNewNoteId(link.targetNoteId);
|
||||
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDirectory(filePath) {
|
||||
const { parentNoteMeta, noteMeta } = getMeta(filePath);
|
||||
|
||||
const noteId = getNoteId(noteMeta, filePath);
|
||||
const noteTitle = getNoteTitle(filePath, noteMeta);
|
||||
const parentNoteId = getParentNoteId(filePath, parentNoteMeta);
|
||||
|
||||
let note = await repository.getNote(noteId);
|
||||
|
||||
if (note) {
|
||||
return;
|
||||
}
|
||||
|
||||
({note} = await noteService.createNote(parentNoteId, noteTitle, '', {
|
||||
noteId,
|
||||
type: noteMeta ? noteMeta.type : 'text',
|
||||
mime: noteMeta ? noteMeta.mime : 'text/html',
|
||||
prefix: noteMeta ? noteMeta.prefix : '',
|
||||
isExpanded: noteMeta ? noteMeta.isExpanded : false
|
||||
}));
|
||||
|
||||
await saveAttributesAndLinks(note, noteMeta);
|
||||
|
||||
if (!firstNote) {
|
||||
firstNote = note;
|
||||
}
|
||||
|
||||
createdPaths[filePath] = noteId;
|
||||
}
|
||||
|
||||
function getTextFileWithoutExtension(filePath) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (extension === '.md' || extension === '.html') {
|
||||
return filePath.substr(0, filePath.length - extension.length);
|
||||
}
|
||||
else {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote(filePath, content) {
|
||||
const {parentNoteMeta, noteMeta} = getMeta(filePath);
|
||||
|
||||
const noteId = getNoteId(noteMeta, filePath);
|
||||
const parentNoteId = getParentNoteId(filePath, parentNoteMeta);
|
||||
|
||||
if (noteMeta && noteMeta.isClone) {
|
||||
await new Branch({
|
||||
noteId,
|
||||
parentNoteId,
|
||||
isExpanded: noteMeta.isExpanded,
|
||||
prefix: noteMeta.prefix,
|
||||
notePosition: noteMeta.notePosition
|
||||
}).save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const {type, mime} = noteMeta ? noteMeta : detectFileTypeAndMime(filePath);
|
||||
|
||||
if (type !== 'file' && type !== 'image') {
|
||||
content = content.toString("UTF-8");
|
||||
|
||||
if (noteMeta) {
|
||||
// this will replace all internal links (<a> and <img>) inside the body
|
||||
// links pointing outside the export will be broken and changed (ctx.getNewNoteId() will still assign new noteId)
|
||||
for (const link of noteMeta.links || []) {
|
||||
// no need to escape the regexp find string since it's a noteId which doesn't contain any special characters
|
||||
content = content.replace(new RegExp(link.targetNoteId, "g"), getNewNoteId(link.targetNoteId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((noteMeta && noteMeta.format === 'markdown') || (!noteMeta && mime === 'text/markdown')) {
|
||||
const parsed = mdReader.parse(content);
|
||||
content = mdWriter.render(parsed);
|
||||
}
|
||||
|
||||
let note = await repository.getNote(noteId);
|
||||
|
||||
if (note) {
|
||||
note.content = content;
|
||||
await note.save();
|
||||
}
|
||||
else {
|
||||
const noteTitle = getNoteTitle(filePath, noteMeta);
|
||||
|
||||
({note} = await noteService.createNote(parentNoteId, noteTitle, content, {
|
||||
noteId,
|
||||
type,
|
||||
mime,
|
||||
prefix: noteMeta ? noteMeta.prefix : '',
|
||||
isExpanded: noteMeta ? noteMeta.isExpanded : false,
|
||||
notePosition: noteMeta ? noteMeta.notePosition : false
|
||||
}));
|
||||
|
||||
await saveAttributesAndLinks(note, noteMeta);
|
||||
|
||||
if (!noteMeta && (type === 'file' || type === 'image')) {
|
||||
attributes.push({
|
||||
noteId,
|
||||
type: 'label',
|
||||
name: 'originalFileName',
|
||||
value: path.basename(filePath)
|
||||
});
|
||||
|
||||
attributes.push({
|
||||
noteId,
|
||||
type: 'label',
|
||||
name: 'fileSize',
|
||||
value: content.byteLength
|
||||
});
|
||||
}
|
||||
|
||||
if (!firstNote) {
|
||||
firstNote = note;
|
||||
}
|
||||
|
||||
if (type === 'text') {
|
||||
filePath = getTextFileWithoutExtension(filePath);
|
||||
}
|
||||
|
||||
createdPaths[filePath] = noteId;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return path without leading or trailing slash and backslashes converted to forward ones*/
|
||||
function normalizeFilePath(filePath) {
|
||||
filePath = filePath.replace(/\\/g, "/");
|
||||
|
||||
if (filePath.startsWith("/")) {
|
||||
filePath = filePath.substr(1);
|
||||
}
|
||||
|
||||
if (filePath.endsWith("/")) {
|
||||
filePath = filePath.substr(0, filePath.length - 1);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
extract.on('entry', function(header, stream, next) {
|
||||
const chunks = [];
|
||||
|
||||
stream.on("data", function (chunk) {
|
||||
@@ -147,11 +316,22 @@ async function parseImportFile(fileBuffer) {
|
||||
// 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);
|
||||
stream.on('end', async function() {
|
||||
let filePath = normalizeFilePath(header.name);
|
||||
|
||||
if (key === "meta") {
|
||||
file[key] = JSON.parse(file[key].toString("UTF-8"));
|
||||
const content = Buffer.concat(chunks);
|
||||
|
||||
if (filePath === '!!!meta.json') {
|
||||
metaFile = JSON.parse(content.toString("UTF-8"));
|
||||
}
|
||||
else if (header.type === 'directory') {
|
||||
await saveDirectory(filePath);
|
||||
}
|
||||
else if (header.type === 'file') {
|
||||
await saveNote(filePath, content);
|
||||
}
|
||||
else {
|
||||
log.info("Ignoring tar import entry with type " + header.type);
|
||||
}
|
||||
|
||||
next(); // ready for next entry
|
||||
@@ -161,8 +341,34 @@ async function parseImportFile(fileBuffer) {
|
||||
});
|
||||
|
||||
return new Promise(resolve => {
|
||||
extract.on('finish', function() {
|
||||
resolve(files);
|
||||
extract.on('finish', async function() {
|
||||
const createdNoteIds = {};
|
||||
|
||||
for (const path in createdPaths) {
|
||||
createdNoteIds[createdPaths[path]] = true;
|
||||
}
|
||||
|
||||
// we're saving attributes and links only now so that all relation and link target notes
|
||||
// are already in the database (we don't want to have "broken" relations, not even transitionally)
|
||||
for (const attr of attributes) {
|
||||
if (attr.type !== 'relation' || attr.value in createdNoteIds) {
|
||||
await new Attribute(attr).save();
|
||||
}
|
||||
else {
|
||||
log.info("Relation not imported since target note doesn't exist: " + JSON.stringify(attr));
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of links) {
|
||||
if (link.targetNoteId in createdNoteIds) {
|
||||
await new Link(link).save();
|
||||
}
|
||||
else {
|
||||
log.info("Link not imported since target note doesn't exist: " + JSON.stringify(link));
|
||||
}
|
||||
}
|
||||
|
||||
resolve(firstNote);
|
||||
});
|
||||
|
||||
const bufferStream = new stream.PassThrough();
|
||||
@@ -172,96 +378,6 @@ async function parseImportFile(fileBuffer) {
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.meta.type !== 'file' && file.meta.type !== 'image') {
|
||||
file.data = file.data.toString("UTF-8");
|
||||
|
||||
// this will replace all internal links (<a> and <img>) inside the body
|
||||
// links pointing outside the export will be broken and changed (ctx.getNewNoteId() will still assign new noteId)
|
||||
for (const link of file.meta.links || []) {
|
||||
// no need to escape the regexp find string since it's a noteId which doesn't contain any special characters
|
||||
file.data = file.data.replace(new RegExp(link.targetNoteId, "g"), ctx.getNewNoteId(link.targetNoteId));
|
||||
}
|
||||
}
|
||||
|
||||
note = (await noteService.createNote(parentNoteId, file.meta.title, file.data, {
|
||||
noteId: ctx.getNewNoteId(file.meta.noteId),
|
||||
type: file.meta.type,
|
||||
mime: file.meta.mime,
|
||||
prefix: file.meta.prefix,
|
||||
isExpanded: !!file.meta.isExpanded
|
||||
})).note;
|
||||
|
||||
ctx.createdNoteIds.push(note.noteId);
|
||||
|
||||
for (const attribute of file.meta.attributes || []) {
|
||||
ctx.attributes.push({
|
||||
noteId: note.noteId,
|
||||
type: attribute.type,
|
||||
name: attribute.name,
|
||||
value: attribute.value,
|
||||
isInheritable: attribute.isInheritable,
|
||||
position: attribute.position
|
||||
});
|
||||
}
|
||||
|
||||
for (const link of file.meta.links || []) {
|
||||
ctx.links.push({
|
||||
noteId: note.noteId,
|
||||
type: link.type,
|
||||
targetNoteId: link.targetNoteId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// first created note will be activated after import
|
||||
returnNote = returnNote || note;
|
||||
|
||||
if (file.children.length > 0) {
|
||||
await importNotes(ctx, file.children, note.noteId);
|
||||
}
|
||||
}
|
||||
|
||||
return returnNote;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
importTar
|
||||
};
|
||||
@@ -38,7 +38,8 @@ async function load() {
|
||||
function highlightResults(results, allTokens) {
|
||||
// we remove < signs because they can cause trouble in matching and overwriting existing highlighted chunks
|
||||
// which would make the resulting HTML string invalid.
|
||||
allTokens = allTokens.map(token => token.replace('/</g', ''));
|
||||
// { and } are used for marking <b> and </b> tag (to avoid matches on single 'b' character)
|
||||
allTokens = allTokens.map(token => token.replace('/[<\{\}]/g', ''));
|
||||
|
||||
// sort by the longest so we first highlight longest matches
|
||||
allTokens.sort((a, b) => a.length > b.length ? -1 : 1);
|
||||
@@ -51,9 +52,15 @@ function highlightResults(results, allTokens) {
|
||||
const tokenRegex = new RegExp("(" + utils.escapeRegExp(token) + ")", "gi");
|
||||
|
||||
for (const result of results) {
|
||||
result.highlighted = result.highlighted.replace(tokenRegex, "<b>$1</b>");
|
||||
result.highlighted = result.highlighted.replace(tokenRegex, "{$1}");
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
result.highlighted = result.highlighted
|
||||
.replace(/{/g, "<b>")
|
||||
.replace(/}/g, "</b>");
|
||||
}
|
||||
}
|
||||
|
||||
function findNotes(query) {
|
||||
@@ -327,11 +334,11 @@ eventService.subscribe(eventService.ENTITY_CHANGED, async ({entityName, entity})
|
||||
|
||||
if (attribute.type === 'label' && attribute.name === 'archived') {
|
||||
// we're not using label object directly, since there might be other non-deleted archived label
|
||||
const hideLabel = await repository.getEntity(`SELECT * FROM attributes WHERE isDeleted = 0 AND type = 'label'
|
||||
const archivedLabel = await repository.getEntity(`SELECT * FROM attributes WHERE isDeleted = 0 AND type = 'label'
|
||||
AND name = 'archived' AND noteId = ?`, [attribute.noteId]);
|
||||
|
||||
if (hideLabel) {
|
||||
archived[attribute.noteId] = hideLabel.isInheritable ? 1 : 0;
|
||||
if (archivedLabel) {
|
||||
archived[attribute.noteId] = archivedLabel.isInheritable ? 1 : 0;
|
||||
}
|
||||
else {
|
||||
delete archived[attribute.noteId];
|
||||
|
||||
@@ -49,10 +49,21 @@ async function triggerNoteTitleChanged(note) {
|
||||
* FIXME: noteData has mandatory property "target", it might be better to add it as parameter to reflect this
|
||||
*/
|
||||
async function createNewNote(parentNoteId, noteData) {
|
||||
const newNotePos = await getNewNotePosition(parentNoteId, noteData);
|
||||
let newNotePos;
|
||||
|
||||
if (noteData.notePosition !== undefined) {
|
||||
newNotePos = noteData.notePosition;
|
||||
}
|
||||
else {
|
||||
newNotePos = await getNewNotePosition(parentNoteId, noteData);
|
||||
}
|
||||
|
||||
const parentNote = await repository.getNote(parentNoteId);
|
||||
|
||||
if (!parentNote) {
|
||||
throw new Error(`Parent note ${parentNoteId} not found.`);
|
||||
}
|
||||
|
||||
if (!noteData.type) {
|
||||
if (parentNote.type === 'text' || parentNote.type === 'code') {
|
||||
noteData.type = parentNote.type;
|
||||
@@ -126,7 +137,8 @@ async function createNote(parentNoteId, title, content = "", extraOptions = {})
|
||||
type: extraOptions.type,
|
||||
mime: extraOptions.mime,
|
||||
dateCreated: extraOptions.dateCreated,
|
||||
isExpanded: extraOptions.isExpanded
|
||||
isExpanded: extraOptions.isExpanded,
|
||||
notePosition: extraOptions.notePosition
|
||||
};
|
||||
|
||||
if (extraOptions.json && !noteData.type) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const sql = require('./sql');
|
||||
const syncTableService = require('../services/sync_table');
|
||||
const eventService = require('./events');
|
||||
const cls = require('./cls');
|
||||
|
||||
let entityConstructor;
|
||||
|
||||
@@ -94,19 +95,22 @@ async function updateEntity(entity) {
|
||||
const primaryKey = entity[primaryKeyName];
|
||||
|
||||
if (entity.isChanged && (entityName !== 'options' || entity.isSynced)) {
|
||||
|
||||
await syncTableService.addEntitySync(entityName, primaryKey);
|
||||
|
||||
const eventPayload = {
|
||||
entityName,
|
||||
entity
|
||||
};
|
||||
if (!cls.isEntityEventsDisabled()) {
|
||||
const eventPayload = {
|
||||
entityName,
|
||||
entity
|
||||
};
|
||||
|
||||
if (isNewEntity && !entity.isDeleted) {
|
||||
await eventService.emit(eventService.ENTITY_CREATED, eventPayload);
|
||||
if (isNewEntity && !entity.isDeleted) {
|
||||
await eventService.emit(eventService.ENTITY_CREATED, eventPayload);
|
||||
}
|
||||
|
||||
// it seems to be better to handle deletion and update separately
|
||||
await eventService.emit(entity.isDeleted ? eventService.ENTITY_DELETED : eventService.ENTITY_CHANGED, eventPayload);
|
||||
}
|
||||
|
||||
// it seems to be better to handle deletion and update separately
|
||||
await eventService.emit(entity.isDeleted ? eventService.ENTITY_DELETED : eventService.ENTITY_CHANGED, eventPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<div id="note-detail-wrapper">
|
||||
<span id="saved-indicator" title="All changes have been saved" class="jam jam-check"></span>
|
||||
|
||||
<div id="note-detail-script-area"></div>
|
||||
|
||||
<table id="note-detail-promoted-attributes"></table>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
title="Reset pan & zoom to initial coordinates and magnification"
|
||||
id="relation-map-reset-pan-zoom" style="right: 100px;"></button>
|
||||
|
||||
<div class="btn-group floating-button" style="right: 20px;">
|
||||
<div class="btn-group floating-button" style="right: 40px;">
|
||||
<button type="button"
|
||||
class="btn icon-button jam jam-search-plus"
|
||||
title="Zoom In"
|
||||
|
||||
67
src/views/dialogs/export.ejs
Normal file
67
src/views/dialogs/export.ejs
Normal file
@@ -0,0 +1,67 @@
|
||||
<div id="export-dialog" class="modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Export note</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<form id="export-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="export-type" id="export-type-subtree" value="subtree">
|
||||
<label class="form-check-label" for="export-type-subtree">this note and all of its descendants</label>
|
||||
</div>
|
||||
|
||||
<div id="export-subtree-formats" class="format-choice">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="export-subtree-format" id="export-subtree-format-html"
|
||||
value="html">
|
||||
<label class="form-check-label" for="export-subtree-format-html">HTML in TAR archiv - this is recommended since this preserves all the formatting.</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="export-subtree-format" id="export-subtree-format-markdown"
|
||||
value="markdown">
|
||||
<label class="form-check-label" for="export-subtree-format-markdown">
|
||||
Markdown - this preserves most of the formatting.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="export-subtree-format" id="export-subtree-format-opml"
|
||||
value="opml">
|
||||
<label class="form-check-label" for="export-subtree-format-opml">
|
||||
OPML - outliner interchange format for text only. Formatting, images and files are not included.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="export-type" id="export-type-single" value="single">
|
||||
<label class="form-check-label" for="export-type-single">only this note without its descendants</label>
|
||||
</div>
|
||||
|
||||
<div id="export-single-formats" class="format-choice">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="export-single-format" id="export-single-format-html" value="html">
|
||||
<label class="form-check-label" for="export-single-format-html">HTML - this is recommended since this preserves all the formatting.</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="export-single-format" id="export-single-format-markdown"
|
||||
value="markdown">
|
||||
<label class="form-check-label" for="export-single-format-markdown">
|
||||
Markdown - this preserves most of the formatting.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary btn-sm">Export</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,46 +0,0 @@
|
||||
<div id="export-subtree-dialog" class="modal fade mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Export subtree</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<form id="export-subtree-form">
|
||||
<div class="modal-body">
|
||||
<div>Export note "<span class="note-title"></span>" and its subtree in the following format:</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="export-format" id="export-format-tar" value="native-tar" checked>
|
||||
<label class="form-check-label" for="export-format-tar">Native TAR - this is Trilium's native format which preserves all notes' data & metadata.</label>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="export-format" id="export-format-opml" value="opml">
|
||||
<label class="form-check-label" for="export-format-opml">
|
||||
OPML - standard outliner interchange format for text only. Formatting, images, files are not included.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="form-check disabled">
|
||||
<input class="form-check-input" type="radio" name="export-format" id="export-format-markdown"
|
||||
value="markdown-tar">
|
||||
<label class="form-check-label" for="export-format-markdown">
|
||||
Markdown - TAR archive of Markdown formatted notes
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary btn-sm">Export</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,33 +16,47 @@
|
||||
</div>
|
||||
|
||||
<div style="flex-grow: 100; display: flex;">
|
||||
<button class="btn btn-sm" id="jump-to-note-dialog-button" title="CTRL+J">Jump to note</button>
|
||||
<button class="btn btn-sm" id="recent-changes-button">Recent changes</button>
|
||||
<div>
|
||||
<span style="font-size: smaller">Protected session:</span>
|
||||
<button class="btn btn-sm" id="jump-to-note-dialog-button" title="CTRL+J">
|
||||
<span class="jam jam-direction"></span>
|
||||
Jump to note
|
||||
</button>
|
||||
|
||||
<div class="btn-group btn-group-xs">
|
||||
<button type="button" class="btn" id="protected-session-on">On</button>
|
||||
<button type="button" class="btn active" id="protected-session-off">Off</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" id="recent-changes-button">
|
||||
<span class="jam jam-history"></span>
|
||||
|
||||
Recent changes
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm" id="enter-protected-session-button" title="Enter protected session to be able to find and view protected notes">
|
||||
<span class="jam jam-door"></span>
|
||||
|
||||
Enter protected session
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm" id="leave-protected-session-button" title="Leave protected session so that protected notes are not accessible any more." style="display: none;">
|
||||
<span class="jam jam-log-out"></span>
|
||||
|
||||
Leave protected session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="plugin-buttons">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-sm" id="sync-now-button" title="Number of outstanding changes to be pushed to server">
|
||||
<button class="btn btn-sm" id="sync-now-button" title="Trigger sync">
|
||||
<span class="jam jam-refresh"></span>
|
||||
|
||||
Sync now (<span id="outstanding-syncs-count">0</span>)
|
||||
Sync (<span id="outstanding-syncs-count">0</span>)
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm" id="options-button">
|
||||
<span class="jam jam-settings-alt"></span> Options</button>
|
||||
|
||||
<form action="logout" id="logout-button" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm">Logout</button>
|
||||
<button type="submit" class="btn btn-sm">
|
||||
<span class="jam jam-log-out"></span>
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +67,7 @@
|
||||
|
||||
<a id="collapse-tree-button" title="Collapse note tree. Shortcut ALT+C" class="icon-action jam jam-align-justify"></a>
|
||||
|
||||
<a id="scroll-to-current-note-button" title="Scroll to current note. Shortcut CTRL+." class="icon-action jam jam-target"></a>
|
||||
<a id="scroll-to-current-note-button" title="Scroll to current note. Shortcut CTRL+." class="icon-action jam jam-download"></a>
|
||||
|
||||
<a id="toggle-search-button" title="Search in notes. Shortcut CTRL+S" class="icon-action jam jam-search"></a>
|
||||
</div>
|
||||
@@ -157,9 +171,9 @@
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item" id="show-note-revisions-button" data-bind="css: { disabled: type() == 'file' || type() == 'image' }">Revisions</a>
|
||||
<a class="dropdown-item show-attributes-button"><kbd>Alt+A</kbd> Attributes</a>
|
||||
<a class="dropdown-item" id="show-source-button" data-bind="css: { disabled: type() != 'text' }">HTML source</a>
|
||||
<a class="dropdown-item" id="show-source-button" data-bind="css: { disabled: type() != 'text' && type() != 'code' && type() != 'relation-map' && type() != 'search' }">Note source</a>
|
||||
<a class="dropdown-item" id="upload-file-button">Upload file</a>
|
||||
<a class="dropdown-item" id="export-note-to-markdown-button" data-bind="css: { disabled: type() != 'text' && type() != 'code' }">Export as markdown</a>
|
||||
<a class="dropdown-item" id="export-note-button" data-bind="css: { disabled: type() != 'text' }">Export note</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,7 +187,7 @@
|
||||
<% include dialogs/attributes.ejs %>
|
||||
<% include dialogs/branch_prefix.ejs %>
|
||||
<% include dialogs/event_log.ejs %>
|
||||
<% include dialogs/export_subtree.ejs %>
|
||||
<% include dialogs/export.ejs %>
|
||||
<% include dialogs/jump_to_note.ejs %>
|
||||
<% include dialogs/markdown_import.ejs %>
|
||||
<% include dialogs/note_revisions.ejs %>
|
||||
|
||||
Reference in New Issue
Block a user