Compare commits

...

26 Commits

Author SHA1 Message Date
azivner
a616739805 release 0.24.5 2018-11-27 15:34:15 +01:00
azivner
bea28de6a0 tab on autocomplete doesn't select first item, closes #243 2018-11-27 13:13:06 +01:00
azivner
4e198ca2f0 storing trilium version into export metadata 2018-11-27 10:31:55 +01:00
azivner
2fbd16a0e3 updated demo export according to latest format changes 2018-11-26 23:50:43 +01:00
azivner
76fc49f037 allow import of single HTML file too 2018-11-26 23:47:02 +01:00
azivner
139c99440f export stores note position and some other fixes 2018-11-26 23:39:43 +01:00
azivner
137b9dfa0b fix storing attributes and relinking noteIds 2018-11-26 23:19:19 +01:00
azivner
5f0fdd15eb fix adding sync entities during import 2018-11-26 22:37:59 +01:00
azivner
61e1427b83 fix matching of "b" in the note autcomplete highlighter 2018-11-26 22:35:19 +01:00
azivner
b3aa0ba47c entity events are not triggered on imported entities 2018-11-26 22:27:57 +01:00
azivner
56e2b44c25 fix import 2018-11-26 22:22:16 +01:00
azivner
4d5a17583f happy path tar import now works 2018-11-26 20:30:43 +01:00
azivner
71eda5aa3d import tar archive WIP 2018-11-26 14:47:46 +01:00
azivner
0711ea8dc8 filter out links and relations which are outside of the export 2018-11-25 22:38:09 +01:00
azivner
be206872d1 changed export model to single metadata file per exported .tar 2018-11-25 22:09:52 +01:00
azivner
fcf3fe8dcd tar export can now solve naming conflict 2018-11-25 15:17:28 +01:00
azivner
62dbd4062a on/off button for entering/leaving protected session has been changed to literal buttons 2018-11-25 14:12:33 +01:00
azivner
196e8b4380 extra icons 2018-11-25 14:05:54 +01:00
azivner
551e1255ff export WIP + some unrelated changes 2018-11-25 10:26:45 +01:00
azivner
e09b61d1ac single file export working, tar WIP 2018-11-24 20:58:38 +01:00
azivner
ee23bcc783 unified export dialog, WIP 2018-11-24 14:44:56 +01:00
azivner
3e351bd8d3 better positioning of nonmodal protected session dialog 2018-11-22 21:24:47 +01:00
azivner
0d3bc22d73 fix z-index of notification 2018-11-22 21:19:12 +01:00
azivner
d82898421e less obtrusive saved indicator, fixes #122 2018-11-22 20:25:49 +01:00
azivner
1db2f0c2c5 improved notifications, now with animations, in center and show up properly in the dialogs 2018-11-22 16:08:02 +01:00
azivner
6cd8a2203e create app icon only for electron build 2018-11-22 00:24:33 +01:00
43 changed files with 1061 additions and 643 deletions

Binary file not shown.

View File

@@ -0,0 +1 @@
UPDATE attributes SET name = 'archived' where name = 'hideInAutocomplete';

17
package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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.

View 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
};

View File

@@ -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
};

View File

@@ -14,7 +14,7 @@ class Branch {
/** @param {string} */
this.prefix = row.prefix;
/** @param {boolean} */
this.isExpanded = row.isExpanded;
this.isExpanded = !!row.isExpanded;
}
/** @returns {NoteShort} */

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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);

View File

@@ -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
};

View File

@@ -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) {

View File

@@ -76,7 +76,8 @@ function initNoteAutocomplete($el, options) {
hint: false,
autoselect: true,
openOnFocus: true,
minLength: 0
minLength: 0,
tabAutocomplete: false
}, [
{
source: autocompleteSource,

View File

@@ -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,

View File

@@ -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.");

View File

@@ -564,8 +564,6 @@ async function createNote(node, parentNoteId, target, isProtected, saveSelection
clearSelectedNodes(); // to unmark previously active node
infoService.showMessage("Created!");
return {note, branch};
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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
};

View File

@@ -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);

View File

@@ -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],

View File

@@ -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;
}

View File

@@ -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 = {

View File

@@ -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" };

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View 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
View 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
};

View File

@@ -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
};

View 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
};

View File

@@ -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
};

View File

@@ -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];

View File

@@ -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) {

View File

@@ -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);
}
});
}

View File

@@ -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>

View File

@@ -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"

View 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">&times;</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>

View File

@@ -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">&times;</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>

View File

@@ -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 %>