mirror of
https://github.com/zadam/trilium.git
synced 2025-10-30 01:36:24 +01:00
Compare commits
40 Commits
v0.28.3
...
v0.29.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc0dd5a80 | ||
|
|
afd5f4823f | ||
|
|
b0cf82c91b | ||
|
|
6a67cdd5af | ||
|
|
bad7b84993 | ||
|
|
d3ca6b5ae6 | ||
|
|
da5009f089 | ||
|
|
c08524c977 | ||
|
|
f89537037e | ||
|
|
c153793766 | ||
|
|
0aec5927d5 | ||
|
|
8aea9a1801 | ||
|
|
73247e3220 | ||
|
|
89344a6eda | ||
|
|
40d2e6ea83 | ||
|
|
910cfe9a17 | ||
|
|
e58a80fc00 | ||
|
|
4a2319cb33 | ||
|
|
5619088c41 | ||
|
|
60271993eb | ||
|
|
6695e8b011 | ||
|
|
707df18b93 | ||
|
|
90895f1288 | ||
|
|
ba1ca506af | ||
|
|
f90ed99a40 | ||
|
|
67630b1a22 | ||
|
|
2c1580ea65 | ||
|
|
840a0b5f64 | ||
|
|
b39f6ef7ad | ||
|
|
fb27088fcd | ||
|
|
76fbff68ba | ||
|
|
54de4d236d | ||
|
|
e211dd65ad | ||
|
|
a87f4d8653 | ||
|
|
b59c175c2e | ||
|
|
580104c4c5 | ||
|
|
0fc3053b0a | ||
|
|
929e0f69c2 | ||
|
|
e70af1300a | ||
|
|
4d0e46021b |
BIN
db/demo.tar
BIN
db/demo.tar
Binary file not shown.
2309
package-lock.json
generated
2309
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "trilium",
|
||||
"productName": "Trilium Notes",
|
||||
"description": "Trilium Notes",
|
||||
"version": "0.28.3",
|
||||
"version": "0.29.0-beta",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "electron.js",
|
||||
"bin": {
|
||||
@@ -29,53 +29,54 @@
|
||||
"cookie-parser": "1.4.3",
|
||||
"debug": "4.1.1",
|
||||
"ejs": "2.6.1",
|
||||
"electron-debug": "2.0.0",
|
||||
"electron-dl": "1.12.0",
|
||||
"electron-debug": "2.1.0",
|
||||
"electron-dl": "1.13.0",
|
||||
"electron-in-page-search": "1.3.2",
|
||||
"express": "4.16.4",
|
||||
"express-session": "1.15.6",
|
||||
"file-type": "10.7.0",
|
||||
"file-type": "10.7.1",
|
||||
"fs-extra": "7.0.1",
|
||||
"get-port": "4.1.0",
|
||||
"helmet": "3.15.0",
|
||||
"html": "1.0.0",
|
||||
"image-type": "3.0.0",
|
||||
"imagemin": "6.0.0",
|
||||
"imagemin": "6.1.0",
|
||||
"imagemin-giflossy": "5.1.10",
|
||||
"imagemin-mozjpeg": "8.0.0",
|
||||
"imagemin-pngquant": "6.0.0",
|
||||
"imagemin-pngquant": "7.0.0",
|
||||
"ini": "1.3.5",
|
||||
"jimp": "0.6.0",
|
||||
"mime-types": "^2.1.21",
|
||||
"moment": "2.23.0",
|
||||
"moment": "2.24.0",
|
||||
"multer": "1.4.1",
|
||||
"node-abi": "2.5.1",
|
||||
"node-abi": "2.6.0",
|
||||
"open": "0.0.5",
|
||||
"rand-token": "0.4.0",
|
||||
"rcedit": "1.1.1",
|
||||
"rimraf": "2.6.2",
|
||||
"rimraf": "2.6.3",
|
||||
"sanitize-filename": "1.6.1",
|
||||
"sax": "^1.2.4",
|
||||
"semver": "^5.6.0",
|
||||
"serve-favicon": "2.5.0",
|
||||
"session-file-store": "1.2.0",
|
||||
"simple-node-logger": "18.12.21",
|
||||
"sqlite": "3.0.0",
|
||||
"sqlite": "3.0.1",
|
||||
"tar-stream": "1.6.2",
|
||||
"turndown": "5.0.1",
|
||||
"turndown": "5.0.3",
|
||||
"unescape": "1.0.1",
|
||||
"ws": "6.1.2",
|
||||
"ws": "6.1.3",
|
||||
"xml2js": "0.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"devtron": "1.4.0",
|
||||
"electron": "4.0.1",
|
||||
"electron-builder": "20.38.4",
|
||||
"electron-compile": "6.4.3",
|
||||
"electron": "4.0.3",
|
||||
"electron-builder": "20.38.5",
|
||||
"electron-compile": "6.4.4",
|
||||
"electron-packager": "13.0.1",
|
||||
"electron-rebuild": "1.8.2",
|
||||
"lorem-ipsum": "1.0.6",
|
||||
"tape": "4.9.1",
|
||||
"xo": "0.23.0"
|
||||
"tape": "4.9.2",
|
||||
"xo": "0.24.0"
|
||||
},
|
||||
"xo": {
|
||||
"envs": [
|
||||
|
||||
@@ -39,7 +39,7 @@ app.use((req, res, next) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.use(bodyParser.json({limit: '50mb'}));
|
||||
app.use(bodyParser.json({limit: '500mb'}));
|
||||
app.use(bodyParser.urlencoded({extended: false}));
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
@@ -63,6 +63,8 @@ app.use(favicon(__dirname + '/public/images/app-icons/win/icon.ico'));
|
||||
|
||||
require('./routes/routes').register(app);
|
||||
|
||||
require('./routes/custom').register(app);
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use((req, res, next) => {
|
||||
const err = new Error('Router not found for request ' + req.url);
|
||||
|
||||
BIN
src/public/images/app-icons/ios/apple-touch-icon.png
Normal file
BIN
src/public/images/app-icons/ios/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -37,6 +37,7 @@ import noteTypeService from './services/note_type.js';
|
||||
import linkService from './services/link.js';
|
||||
import noteAutocompleteService from './services/note_autocomplete.js';
|
||||
import macInit from './services/mac_init.js';
|
||||
import cssLoader from './services/css_loader.js';
|
||||
|
||||
// required for CKEditor image upload plugin
|
||||
window.glob.getCurrentNode = treeService.getCurrentNode;
|
||||
@@ -79,6 +80,10 @@ window.onerror = function (msg, url, lineNo, columnNo, error) {
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const appCssNoteId of window.appCssNoteIds) {
|
||||
cssLoader.requireCss(`/api/notes/download/${appCssNoteId}`);
|
||||
}
|
||||
|
||||
const wikiBaseUrl = "https://github.com/zadam/trilium/wiki/";
|
||||
|
||||
$(document).on("click", "button[data-help-page]", e => {
|
||||
@@ -121,6 +126,8 @@ $("#export-note-button").click(function () {
|
||||
|
||||
macInit.init();
|
||||
|
||||
searchNotesService.init(); // should be in front of treeService since that one manipulates address bar hash
|
||||
|
||||
treeService.showTree();
|
||||
|
||||
entrypoints.registerEntrypoints();
|
||||
|
||||
@@ -97,7 +97,7 @@ function AttributesModel() {
|
||||
await showAttributes(attributes);
|
||||
|
||||
// attribute might not be rendered immediatelly so could not focus
|
||||
setTimeout(() => $(".attribute-type-select:last").focus(), 100);
|
||||
setTimeout(() => $(".attribute-type-select:last").focus(), 1000);
|
||||
};
|
||||
|
||||
this.deleteAttribute = function(data, event) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import server from '../services/server.js';
|
||||
import infoService from "../services/info.js";
|
||||
import zoomService from "../services/zoom.js";
|
||||
import utils from "../services/utils.js";
|
||||
import cssLoader from "../services/css_loader.js";
|
||||
|
||||
const $dialog = $("#options-dialog");
|
||||
|
||||
@@ -50,7 +51,22 @@ addTabHandler((function() {
|
||||
const $body = $("body");
|
||||
const $container = $("#container");
|
||||
|
||||
function optionsLoaded(options) {
|
||||
async function optionsLoaded(options) {
|
||||
const themes = [
|
||||
{ val: 'white', title: 'White' },
|
||||
{ val: 'dark', title: 'Dark' },
|
||||
{ val: 'black', title: 'Black' }
|
||||
].concat(await server.get('options/user-themes'));
|
||||
|
||||
$themeSelect.empty();
|
||||
|
||||
for (const theme of themes) {
|
||||
$themeSelect.append($("<option>")
|
||||
.attr("value", theme.val)
|
||||
.attr("data-note-id", theme.noteId)
|
||||
.html(theme.title));
|
||||
}
|
||||
|
||||
$themeSelect.val(options.theme);
|
||||
|
||||
if (utils.isElectron()) {
|
||||
@@ -71,12 +87,20 @@ addTabHandler((function() {
|
||||
$themeSelect.change(function() {
|
||||
const newTheme = $(this).val();
|
||||
|
||||
for (const clazz of $body[0].classList) {
|
||||
for (const clazz of Array.from($body[0].classList)) { // create copy to safely iterate over while removing classes
|
||||
if (clazz.startsWith("theme-")) {
|
||||
$body.removeClass(clazz);
|
||||
}
|
||||
}
|
||||
|
||||
const noteId = $(this).find(":selected").attr("data-note-id");
|
||||
|
||||
if (noteId) {
|
||||
// make sure the CSS is loaded
|
||||
// if the CSS has been loaded and then updated then the changes won't take effect though
|
||||
cssLoader.requireCss(`/api/notes/download/${noteId}`);
|
||||
}
|
||||
|
||||
$body.addClass("theme-" + newTheme);
|
||||
|
||||
server.put('options/theme/' + newTheme);
|
||||
|
||||
13
src/public/javascripts/services/css_loader.js
Normal file
13
src/public/javascripts/services/css_loader.js
Normal file
@@ -0,0 +1,13 @@
|
||||
async function requireCss(url) {
|
||||
const css = Array
|
||||
.from(document.querySelectorAll('link'))
|
||||
.map(scr => scr.href);
|
||||
|
||||
if (!css.includes(url)) {
|
||||
$('head').append($('<link rel="stylesheet" type="text/css" />').attr('href', url));
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
requireCss
|
||||
}
|
||||
@@ -9,6 +9,14 @@ const dragAndDropSetup = {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.originalEvent.ctrlKey) {
|
||||
// keep existing selection only if CTRL key is pressed
|
||||
for (const selectedNode of treeService.getSelectedNodes()) {
|
||||
selectedNode.setSelected(false);
|
||||
selectedNode.renderTitle();
|
||||
}
|
||||
}
|
||||
|
||||
node.setSelected(true);
|
||||
|
||||
// this is for dragging notes into relation map
|
||||
|
||||
@@ -61,8 +61,15 @@ function registerEntrypoints() {
|
||||
$("#history-back-button").click(window.history.back);
|
||||
$("#history-forward-button").click(window.history.forward);
|
||||
|
||||
utils.bindShortcut('alt+left', window.history.back);
|
||||
utils.bindShortcut('alt+right', window.history.forward);
|
||||
if (utils.isMac()) {
|
||||
// Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376
|
||||
utils.bindShortcut('meta+left', window.history.back);
|
||||
utils.bindShortcut('meta+right', window.history.forward);
|
||||
}
|
||||
else {
|
||||
utils.bindShortcut('alt+left', window.history.back);
|
||||
utils.bindShortcut('alt+right', window.history.forward);
|
||||
}
|
||||
}
|
||||
|
||||
utils.bindShortcut('alt+m', e => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import cssLoader from './css_loader.js';
|
||||
|
||||
const CKEDITOR = {"js": ["libraries/ckeditor/ckeditor.js"]};
|
||||
|
||||
const CODE_MIRROR = {
|
||||
@@ -34,7 +36,7 @@ const RELATION_MAP = {
|
||||
|
||||
async function requireLibrary(library) {
|
||||
if (library.css) {
|
||||
library.css.map(cssUrl => requireCss(cssUrl));
|
||||
library.css.map(cssUrl => cssLoader.requireCss(cssUrl));
|
||||
}
|
||||
|
||||
if (library.js) {
|
||||
@@ -59,16 +61,6 @@ async function requireScript(url) {
|
||||
await loadedScriptPromises[url];
|
||||
}
|
||||
|
||||
async function requireCss(url) {
|
||||
const css = Array
|
||||
.from(document.querySelectorAll('link'))
|
||||
.map(scr => scr.href);
|
||||
|
||||
if (!css.includes(url)) {
|
||||
$('head').append($('<link rel="stylesheet" type="text/css" />').attr('href', url));
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
requireLibrary,
|
||||
CKEDITOR,
|
||||
|
||||
@@ -107,6 +107,7 @@ function init() {
|
||||
$(document).on('click', "a[data-action='note']", goToLink);
|
||||
$(document).on('click', 'div.popover-content a, div.ui-tooltip-content a', goToLink);
|
||||
$(document).on('dblclick', '#note-detail-text a', goToLink);
|
||||
$(document).on('click', '#note-detail-text.ck-read-only a', goToLink);
|
||||
$(document).on('click', 'span.ck-button__label', e => {
|
||||
// this is a link preview dialog from CKEditor link editing
|
||||
// for some reason clicked element is span
|
||||
|
||||
@@ -30,6 +30,7 @@ const $noteIdDisplay = $("#note-id-display");
|
||||
const $childrenOverview = $("#children-overview");
|
||||
const $scriptArea = $("#note-detail-script-area");
|
||||
const $savedIndicator = $("#saved-indicator");
|
||||
const $body = $("body");
|
||||
|
||||
let currentNote = null;
|
||||
|
||||
@@ -145,12 +146,21 @@ async function saveNoteIfChanged() {
|
||||
$savedIndicator.fadeIn();
|
||||
}
|
||||
|
||||
function setNoteBackgroundIfProtected(note) {
|
||||
$noteDetailWrapper.toggleClass("protected", note.isProtected);
|
||||
$protectButton.toggleClass("active", note.isProtected);
|
||||
$protectButton.prop("disabled", note.isProtected);
|
||||
$unprotectButton.toggleClass("active", !note.isProtected);
|
||||
$unprotectButton.prop("disabled", !note.isProtected || !protectedSessionHolder.isProtectedSessionAvailable());
|
||||
function updateNoteView() {
|
||||
$noteDetailWrapper.toggleClass("protected", currentNote.isProtected);
|
||||
$protectButton.toggleClass("active", currentNote.isProtected);
|
||||
$protectButton.prop("disabled", currentNote.isProtected);
|
||||
$unprotectButton.toggleClass("active", !currentNote.isProtected);
|
||||
$unprotectButton.prop("disabled", !currentNote.isProtected || !protectedSessionHolder.isProtectedSessionAvailable());
|
||||
|
||||
for (const clazz of Array.from($body[0].classList)) { // create copy to safely iterate over while removing classes
|
||||
if (clazz.startsWith("type-") || clazz.startsWith("mime-")) {
|
||||
$body.removeClass(clazz);
|
||||
}
|
||||
}
|
||||
|
||||
$body.addClass(utils.getNoteTypeClass(currentNote.type));
|
||||
$body.addClass(utils.getMimeTypeClass(currentNote.mime));
|
||||
}
|
||||
|
||||
async function handleProtectedSession() {
|
||||
@@ -193,7 +203,7 @@ async function loadNoteDetail(noteId) {
|
||||
|
||||
$noteIdDisplay.html(noteId);
|
||||
|
||||
setNoteBackgroundIfProtected(currentNote);
|
||||
updateNoteView();
|
||||
|
||||
$noteDetailWrapper.show();
|
||||
|
||||
@@ -270,7 +280,7 @@ async function showChildrenOverview() {
|
||||
text: await treeUtils.getNoteTitle(childBranch.noteId, childBranch.parentNoteId)
|
||||
}).attr('data-action', 'note').attr('data-note-path', notePath + '/' + childBranch.noteId);
|
||||
|
||||
const childEl = $('<div class="child-overview">').html(link);
|
||||
const childEl = $('<div class="child-overview-item">').html(link);
|
||||
$childrenOverview.append(childEl);
|
||||
}
|
||||
|
||||
@@ -344,7 +354,7 @@ setInterval(saveNoteIfChanged, 3000);
|
||||
export default {
|
||||
reload,
|
||||
switchToNote,
|
||||
setNoteBackgroundIfProtected,
|
||||
updateNoteView,
|
||||
loadNote,
|
||||
getCurrentNote,
|
||||
getCurrentNoteType,
|
||||
|
||||
@@ -5,6 +5,7 @@ import noteDetailService from "./note_detail.js";
|
||||
|
||||
const $component = $('#note-detail-file');
|
||||
|
||||
const $fileNoteId = $("#file-note-id");
|
||||
const $fileName = $("#file-filename");
|
||||
const $fileType = $("#file-filetype");
|
||||
const $fileSize = $("#file-filesize");
|
||||
@@ -21,6 +22,7 @@ async function show() {
|
||||
|
||||
$component.show();
|
||||
|
||||
$fileNoteId.text(currentNote.noteId);
|
||||
$fileName.text(attributeMap.originalFileName || "?");
|
||||
$fileSize.text((attributeMap.fileSize || "?") + " bytes");
|
||||
$fileType.text(currentNote.mime);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import libraryLoader from "./library_loader.js";
|
||||
import noteDetailService from './note_detail.js';
|
||||
import treeService from './tree.js';
|
||||
import attributeService from "./attributes.js";
|
||||
|
||||
const $component = $('#note-detail-text');
|
||||
|
||||
@@ -19,6 +20,8 @@ async function show() {
|
||||
}
|
||||
}
|
||||
|
||||
textEditor.isReadOnly = await isReadOnly();
|
||||
|
||||
textEditor.setData(noteDetailService.getCurrentNote().content);
|
||||
|
||||
$component.show();
|
||||
@@ -36,6 +39,12 @@ function getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
async function isReadOnly() {
|
||||
const attributes = await attributeService.getAttributes();
|
||||
|
||||
return attributes.some(attr => attr.type === 'label' && attr.name === 'readOnly');
|
||||
}
|
||||
|
||||
function focus() {
|
||||
$component.focus();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import treeService from './tree.js';
|
||||
import noteDetailService from './note_detail.js';
|
||||
import server from './server.js';
|
||||
import infoService from "./info.js";
|
||||
import confirmDialog from "../dialogs/confirm.js";
|
||||
|
||||
const $executeScriptButton = $("#execute-script-button");
|
||||
const $toggleEditButton = $('#toggle-edit-button');
|
||||
@@ -110,35 +111,63 @@ function NoteTypeModel() {
|
||||
self.updateExecuteScriptButtonVisibility();
|
||||
}
|
||||
|
||||
this.selectText = function() {
|
||||
function confirmChangeIfContent() {
|
||||
if (!noteDetailService.getCurrentNoteContent()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return confirmDialog.confirm("It is not recommended to change note type when note content is not empty. Do you want to continue anyway?");
|
||||
}
|
||||
|
||||
this.selectText = async function() {
|
||||
if (!await confirmChangeIfContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.type('text');
|
||||
self.mime('');
|
||||
self.mime('text/html');
|
||||
|
||||
save();
|
||||
};
|
||||
|
||||
this.selectRender = function() {
|
||||
this.selectRender = async function() {
|
||||
if (!await confirmChangeIfContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.type('render');
|
||||
self.mime('text/html');
|
||||
|
||||
save();
|
||||
};
|
||||
|
||||
this.selectRelationMap = function() {
|
||||
this.selectRelationMap = async function() {
|
||||
if (!await confirmChangeIfContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.type('relation-map');
|
||||
self.mime('application/json');
|
||||
|
||||
save();
|
||||
};
|
||||
|
||||
this.selectCode = function() {
|
||||
this.selectCode = async function() {
|
||||
if (!await confirmChangeIfContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.type('code');
|
||||
self.mime('');
|
||||
self.mime('text/plain');
|
||||
|
||||
save();
|
||||
};
|
||||
|
||||
this.selectCodeMime = function(el) {
|
||||
this.selectCodeMime = async function(el) {
|
||||
if (!await confirmChangeIfContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.type('code');
|
||||
self.mime(el.mime);
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ async function protectNoteAndSendToServer() {
|
||||
|
||||
treeService.setProtected(note.noteId, note.isProtected);
|
||||
|
||||
noteDetailService.setNoteBackgroundIfProtected(note);
|
||||
noteDetailService.updateNoteView();
|
||||
}
|
||||
|
||||
async function unprotectNoteAndSendToServer() {
|
||||
@@ -152,7 +152,7 @@ async function unprotectNoteAndSendToServer() {
|
||||
|
||||
treeService.setProtected(currentNote.noteId, currentNote.isProtected);
|
||||
|
||||
noteDetailService.setNoteBackgroundIfProtected(currentNote);
|
||||
noteDetailService.updateNoteView();
|
||||
}
|
||||
|
||||
async function protectSubtree(noteId, protect) {
|
||||
|
||||
@@ -76,6 +76,15 @@ async function saveSearch() {
|
||||
await treeService.activateNote(noteId);
|
||||
}
|
||||
|
||||
function init() {
|
||||
const hashValue = treeService.getHashValueFromAddress();
|
||||
|
||||
if (hashValue.startsWith("search=")) {
|
||||
showSearch();
|
||||
doSearch(hashValue.substr(7));
|
||||
}
|
||||
}
|
||||
|
||||
$searchInput.keyup(e => {
|
||||
const searchText = $searchInput.val();
|
||||
|
||||
@@ -100,5 +109,6 @@ export default {
|
||||
toggleSearch,
|
||||
resetSearch,
|
||||
showSearch,
|
||||
doSearch
|
||||
doSearch,
|
||||
init
|
||||
};
|
||||
@@ -483,16 +483,20 @@ async function reload() {
|
||||
await getTree().reload(notes);
|
||||
}
|
||||
|
||||
function getNotePathFromAddress() {
|
||||
return document.location.hash.substr(1); // strip initial #
|
||||
function isNotePathInAddress() {
|
||||
return getHashValueFromAddress().startsWith("root");
|
||||
}
|
||||
|
||||
function getHashValueFromAddress() {
|
||||
return document.location.hash ? document.location.hash.substr(1) : ""; // strip initial #
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
const resp = await server.get('tree');
|
||||
startNotePath = resp.startNotePath;
|
||||
|
||||
if (document.location.hash) {
|
||||
startNotePath = getNotePathFromAddress();
|
||||
if (isNotePathInAddress()) {
|
||||
startNotePath = getHashValueFromAddress();
|
||||
}
|
||||
|
||||
return await treeBuilder.prepareTree(resp.notes, resp.branches, resp.relations);
|
||||
@@ -707,12 +711,14 @@ utils.bindShortcut('ctrl+p', createNoteInto);
|
||||
utils.bindShortcut('ctrl+.', scrollToCurrentNote);
|
||||
|
||||
$(window).bind('hashchange', function() {
|
||||
const notePath = getNotePathFromAddress();
|
||||
if (isNotePathInAddress()) {
|
||||
const notePath = getHashValueFromAddress();
|
||||
|
||||
if (notePath !== '-' && getCurrentNotePath() !== notePath) {
|
||||
console.debug("Switching to " + notePath + " because of hash change");
|
||||
if (notePath !== '-' && getCurrentNotePath() !== notePath) {
|
||||
console.debug("Switching to " + notePath + " because of hash change");
|
||||
|
||||
activateNote(notePath);
|
||||
activateNote(notePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -745,5 +751,6 @@ export default {
|
||||
showTree,
|
||||
loadTree,
|
||||
treeInitialized,
|
||||
setExpandedToServer
|
||||
setExpandedToServer,
|
||||
getHashValueFromAddress
|
||||
};
|
||||
@@ -166,26 +166,15 @@ async function getExtraClasses(note) {
|
||||
extraClasses.push(note.cssClass);
|
||||
}
|
||||
|
||||
extraClasses.push(note.type);
|
||||
extraClasses.push(utils.getNoteTypeClass(note.type));
|
||||
|
||||
if (note.mime) { // some notes should not have mime type (e.g. render)
|
||||
extraClasses.push(getMimeTypeClass(note.mime));
|
||||
extraClasses.push(utils.getMimeTypeClass(note.mime));
|
||||
}
|
||||
|
||||
return extraClasses.join(" ");
|
||||
}
|
||||
|
||||
function getMimeTypeClass(mime) {
|
||||
const semicolonIdx = mime.indexOf(';');
|
||||
|
||||
if (semicolonIdx !== -1) {
|
||||
// stripping everything following the semicolon
|
||||
mime = mime.substr(0, semicolonIdx);
|
||||
}
|
||||
|
||||
return 'mime-' + mime.toLowerCase().replace(/[\W_]+/g,"-");
|
||||
}
|
||||
|
||||
export default {
|
||||
prepareTree,
|
||||
prepareBranch,
|
||||
|
||||
@@ -172,6 +172,21 @@ function setCookie(name, value) {
|
||||
document.cookie = name + "=" + (value || "") + expires + "; path=/";
|
||||
}
|
||||
|
||||
function getNoteTypeClass(type) {
|
||||
return "type-" + type;
|
||||
}
|
||||
|
||||
function getMimeTypeClass(mime) {
|
||||
const semicolonIdx = mime.indexOf(';');
|
||||
|
||||
if (semicolonIdx !== -1) {
|
||||
// stripping everything following the semicolon
|
||||
mime = mime.substr(0, semicolonIdx);
|
||||
}
|
||||
|
||||
return 'mime-' + mime.toLowerCase().replace(/[\W_]+/g,"-");
|
||||
}
|
||||
|
||||
export default {
|
||||
reloadApp,
|
||||
parseDate,
|
||||
@@ -198,5 +213,7 @@ export default {
|
||||
bindShortcut,
|
||||
isMobile,
|
||||
isDesktop,
|
||||
setCookie
|
||||
setCookie,
|
||||
getNoteTypeClass,
|
||||
getMimeTypeClass
|
||||
};
|
||||
4
src/public/libraries/ckeditor/ckeditor.js
vendored
4
src/public/libraries/ckeditor/ckeditor.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -30,6 +30,7 @@ body {
|
||||
flex-shrink: 1;
|
||||
flex-basis: 60%;
|
||||
margin-top: 10px;
|
||||
font-family: var(--tree-font-family);
|
||||
font-size: var(--tree-font-size);
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ body {
|
||||
justify-content: space-around;
|
||||
padding: 10px 0 10px 0;
|
||||
margin: 0 20px 0 10px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
@@ -94,4 +95,13 @@ body {
|
||||
|
||||
#note-detail-wrapper {
|
||||
font-size: var(--detail-font-size);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
:root {
|
||||
--main-font-family: inherit;
|
||||
--main-font-size: normal;
|
||||
--tree-font-family: inherit;
|
||||
--tree-font-size: normal;
|
||||
--detail-font-family: inherit;
|
||||
--detail-font-size: normal;
|
||||
--detail-text-font-family: inherit;
|
||||
|
||||
--main-background-color: white;
|
||||
--main-text-color: black;
|
||||
--main-border-color: #ccc;
|
||||
--accented-background-color: #eee;
|
||||
--more-accented-background-color: #ccc;
|
||||
--header-background-color: #f8f8f8;
|
||||
--button-background-color: #eee;
|
||||
--button-disabled-background-color: #ccc;
|
||||
--button-border-color: #ddd;
|
||||
--button-text-color: black;
|
||||
--button-border-radius: 5px;
|
||||
@@ -27,6 +33,7 @@
|
||||
body.theme-black {
|
||||
--main-background-color: black;
|
||||
--main-text-color: white;
|
||||
--main-border-color: #ddd;
|
||||
--accented-background-color: #222;
|
||||
--more-accented-background-color: #444;
|
||||
--header-background-color: black;
|
||||
@@ -53,6 +60,7 @@ body.theme-black .CodeMirror {
|
||||
body.theme-dark {
|
||||
--main-background-color: #333;
|
||||
--main-text-color: white;
|
||||
--main-border-color: #ddd;
|
||||
--accented-background-color: #555;
|
||||
--more-accented-background-color: #777;
|
||||
--header-background-color: #333;
|
||||
@@ -88,6 +96,7 @@ body {
|
||||
width: 100%;
|
||||
background-color: var(--main-background-color);
|
||||
color: var(--main-text-color);
|
||||
font-family: var(--main-font-family);
|
||||
}
|
||||
|
||||
input, select {
|
||||
@@ -157,7 +166,7 @@ ul.fancytree-container {
|
||||
|
||||
/* this is done to preserve correct indentation. Better solution would be preferable */
|
||||
.fancytree-node:not(.fancytree-folder) .fancytree-expander:before {
|
||||
color: white;
|
||||
color: var(--main-background-color); /* setting to background color makes this invisible */
|
||||
}
|
||||
|
||||
.fancytree-node.fancytree-expanded .fancytree-expander:before {
|
||||
@@ -180,6 +189,7 @@ ul.fancytree-container {
|
||||
padding-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--detail-font-family);
|
||||
}
|
||||
|
||||
#note-detail-component-wrapper {
|
||||
@@ -211,6 +221,11 @@ ul.fancytree-container {
|
||||
/* This is because with empty content height of editor is 0 and it's impossible to click into it */
|
||||
min-height: 200px;
|
||||
overflow: auto;
|
||||
font-family: var(--detail-text-font-family);
|
||||
}
|
||||
|
||||
#note-detail-text p:first-child, #note-detail-text::before {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/** we disable shield background when in distraction free mode because I couldn't get it to stay static
|
||||
@@ -256,21 +271,31 @@ span.fancytree-node.fancytree-active-clone:not(.fancytree-active) .fancytree-tit
|
||||
span.fancytree-active.fancytree-focused .fancytree-title {
|
||||
color: var(--active-item-text-color) !important;
|
||||
background-color: var(--active-item-background-color) !important;
|
||||
border-color: #ddd !important;
|
||||
border-color: var(--main-border-color) !important;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
span.fancytree-active:not(.fancytree-focused) .fancytree-title, span.fancytree-selected .fancytree-title {
|
||||
span.fancytree-active:not(.fancytree-focused) .fancytree-title {
|
||||
color: var(--hover-item-text-color) !important;
|
||||
background-color: var(--hover-item-background-color) !important;
|
||||
border-color: #ddd !important;
|
||||
border-color: var(--main-border-color) !important;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
span.fancytree-selected:not(.fancytree-active) .fancytree-title {
|
||||
color: var(--hover-item-text-color) !important;
|
||||
background-color: var(--hover-item-background-color) !important;
|
||||
border-color: var(--main-border-color) !important;
|
||||
border-radius: 3px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
span.fancytree-node:not(.fancytree-active):hover span.fancytree-title {
|
||||
color: var(--hover-item-text-color) !important;
|
||||
background-color: var(--hover-item-background-color) !important;
|
||||
border-color: #ddd !important;
|
||||
border-color: var(--main-border-color) !important;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -313,13 +338,17 @@ div.ui-tooltip {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
overflow: auto;
|
||||
border-bottom: 2px solid #ddd;
|
||||
border-bottom: 2px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
#search-results ul {
|
||||
padding: 5px 5px 5px 15px;
|
||||
}
|
||||
|
||||
#search-text {
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
/*
|
||||
* .electron-in-page-search-window is a class specified to default
|
||||
* <webview> element for search window.
|
||||
@@ -430,6 +459,11 @@ div.ui-tooltip {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.CodeMirror-gutters {
|
||||
background-color: inherit !important;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
#note-id-display {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
@@ -450,10 +484,12 @@ div.ui-tooltip {
|
||||
overflow: auto;
|
||||
/* limiting the size since actual note content is more important */
|
||||
max-height: 30%;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 2em;
|
||||
}
|
||||
|
||||
#label-list, #relation-list, #attribute-list {
|
||||
color: #777777;
|
||||
color: var(--muted-text-color);
|
||||
padding: 5px;
|
||||
display: none;
|
||||
}
|
||||
@@ -479,9 +515,8 @@ div.ui-tooltip {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.child-overview {
|
||||
.child-overview-item {
|
||||
font-weight: bold;
|
||||
font-size: larger;
|
||||
padding: 10px;
|
||||
background: var(--accented-background-color);
|
||||
width: 150px;
|
||||
@@ -496,7 +531,7 @@ div.ui-tooltip {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.child-overview a {
|
||||
.child-overview-item a {
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
@@ -522,7 +557,7 @@ div.ui-tooltip {
|
||||
}
|
||||
|
||||
.btn.active:not(.btn-primary) {
|
||||
background-color: #ccc !important;
|
||||
background-color: var(--button-disabled-background-color) !important;
|
||||
}
|
||||
|
||||
#note-path-list a.current {
|
||||
@@ -569,8 +604,12 @@ button.icon-button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre:not(.CodeMirror-line) {
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
|
||||
#file-preview-content {
|
||||
background-color: #f6f6f6;
|
||||
background-color: var(--accented-background-color);
|
||||
padding: 15px;
|
||||
max-width: 600px;
|
||||
max-height: 300px;
|
||||
@@ -612,7 +651,7 @@ button.icon-button {
|
||||
|
||||
.go-to-selected-note-button.disabled, .go-to-selected-note-button.disabled:hover {
|
||||
cursor: inherit;
|
||||
color: #ccc !important;
|
||||
color: var(--button-disabled-background-color) !important;
|
||||
}
|
||||
|
||||
.note-autocomplete-input {
|
||||
@@ -651,7 +690,7 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
color: var(--main-text-color);
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-radius: 5px;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -681,7 +720,7 @@ table.promoted-attributes-in-tooltip td, table.promoted-attributes-in-tooltip th
|
||||
.algolia-autocomplete .aa-dropdown-menu {
|
||||
width: 100%;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid #999;
|
||||
border: 1px solid var(--main-border-color);
|
||||
border-top: none;
|
||||
z-index: 2000 !important;
|
||||
max-height: 500px;
|
||||
@@ -768,9 +807,9 @@ div[data-notify="container"] {
|
||||
#saved-indicator {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 11px;
|
||||
top: -7px;
|
||||
font-size: 150%;
|
||||
color: #777;
|
||||
color: var(--main-text-color);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -807,7 +846,7 @@ div[data-notify="container"] {
|
||||
|
||||
margin: var(--ck-spacing-large) 0;
|
||||
|
||||
color: #aaa;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.fancytree-loading span.fancytree-expander {
|
||||
|
||||
@@ -34,8 +34,7 @@ async function uploadFile(req) {
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFile(req, res) {
|
||||
const noteId = req.params.noteId;
|
||||
async function downloadNoteFile(noteId, res) {
|
||||
const note = await repository.getNote(noteId);
|
||||
|
||||
if (!note) {
|
||||
@@ -43,8 +42,7 @@ async function downloadFile(req, res) {
|
||||
}
|
||||
|
||||
if (note.isProtected && !protectedSessionService.isProtectedSessionAvailable()) {
|
||||
res.status(401).send("Protected session not available");
|
||||
return;
|
||||
return res.status(401).send("Protected session not available");
|
||||
}
|
||||
|
||||
const originalFileName = await note.getLabel('originalFileName');
|
||||
@@ -56,7 +54,15 @@ async function downloadFile(req, res) {
|
||||
res.send(note.content);
|
||||
}
|
||||
|
||||
async function downloadFile(req, res) {
|
||||
const noteId = req.params.noteId;
|
||||
|
||||
return await downloadNoteFile(noteId, res);
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadFile,
|
||||
downloadFile
|
||||
downloadFile,
|
||||
downloadNoteFile
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
const sql = require('../../services/sql');
|
||||
const optionService = require('../../services/options');
|
||||
const log = require('../../services/log');
|
||||
const attributes = require('../../services/attributes');
|
||||
|
||||
// options allowed to be updated directly in options dialog
|
||||
const ALLOWED_OPTIONS = ['protectedSessionTimeout', 'noteRevisionSnapshotTimeInterval',
|
||||
@@ -42,8 +42,31 @@ async function update(name, value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getUserThemes() {
|
||||
const notes = await attributes.getNotesWithLabel('appTheme');
|
||||
|
||||
const ret = [];
|
||||
|
||||
for (const note of notes) {
|
||||
let value = await note.getLabelValue('appTheme');
|
||||
|
||||
if (!value) {
|
||||
value = note.title.toLowerCase().replace(/[^a-z0-9]/gi, '-');
|
||||
}
|
||||
|
||||
ret.push({
|
||||
val: value,
|
||||
title: note.title,
|
||||
noteId: note.noteId
|
||||
});
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOptions,
|
||||
updateOption,
|
||||
updateOptions
|
||||
updateOptions,
|
||||
getUserThemes
|
||||
};
|
||||
@@ -19,7 +19,7 @@ async function exec(req) {
|
||||
async function run(req) {
|
||||
const note = await repository.getNote(req.params.noteId);
|
||||
|
||||
const result = await scriptService.executeNote(note, note);
|
||||
const result = await scriptService.executeNote(note, { originEntity: note });
|
||||
|
||||
return { executionResult: result };
|
||||
}
|
||||
|
||||
66
src/routes/custom.js
Normal file
66
src/routes/custom.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const repository = require('../services/repository');
|
||||
const log = require('../services/log');
|
||||
const fileUploadService = require('./api/file_upload');
|
||||
const scriptService = require('../services/script');
|
||||
|
||||
function register(router) {
|
||||
router.all('/custom/:path*', async (req, res, next) => {
|
||||
// express puts content after first slash into 0 index element
|
||||
const path = req.params.path + req.params[0];
|
||||
|
||||
const attrs = await repository.getEntities("SELECT * FROM attributes WHERE isDeleted = 0 AND type = 'label' AND name IN ('customRequestHandler', 'customResourceProvider')");
|
||||
|
||||
for (const attr of attrs) {
|
||||
const regex = new RegExp(attr.value);
|
||||
let match;
|
||||
|
||||
try {
|
||||
match = path.match(regex);
|
||||
}
|
||||
catch (e) {
|
||||
log.error(`Testing path for label ${attr.attributeId}, regex=${attr.value} failed with error ` + e.stack);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attr.name === 'customRequestHandler') {
|
||||
const note = await attr.getNote();
|
||||
|
||||
log.info(`Handling custom request "${path}" with note ${note.noteId}`);
|
||||
|
||||
try {
|
||||
await scriptService.executeNote(note, {
|
||||
pathParams: match.slice(1),
|
||||
req,
|
||||
res
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
log.error(`Custom handler ${note.noteId} failed with ${e.message}`);
|
||||
|
||||
res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
else if (attr.name === 'customResourceProvider') {
|
||||
await fileUploadService.downloadNoteFile(attr.noteId, res);
|
||||
}
|
||||
else {
|
||||
throw new Error("Unrecognized attribute name " + attr.name);
|
||||
}
|
||||
|
||||
return; // only first handler is executed
|
||||
}
|
||||
|
||||
const message = `No handler matched for custom ${path} request.`;
|
||||
|
||||
log.info(message);
|
||||
res.status(404).send(message);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
register
|
||||
};
|
||||
@@ -22,22 +22,13 @@ async function index(req, res) {
|
||||
sourceId: await sourceIdService.generateSourceId(),
|
||||
maxSyncIdAtLoad: await sql.getValue("SELECT MAX(id) FROM sync"),
|
||||
instanceName: config.General ? config.General.instanceName : null,
|
||||
appCss: await getAppCss()
|
||||
appCssNoteIds: await getAppCssNoteIds()
|
||||
});
|
||||
}
|
||||
|
||||
async function getAppCss() {
|
||||
let css = '';
|
||||
const notes = attributeService.getNotesWithLabel('appCss');
|
||||
|
||||
for (const note of await notes) {
|
||||
css += `/* ${note.noteId} */
|
||||
${note.content}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
return css;
|
||||
async function getAppCssNoteIds() {
|
||||
return (await attributeService.getNotesWithLabels(['appCss', 'appTheme']))
|
||||
.map(note => note.noteId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -135,6 +135,8 @@ function register(app) {
|
||||
filesRoute.uploadFile, apiResultHandler);
|
||||
|
||||
route(GET, '/api/notes/:noteId/download', [auth.checkApiAuthOrElectron], filesRoute.downloadFile);
|
||||
// this "hacky" path is used for easier referencing of CSS resources
|
||||
route(GET, '/api/notes/download/:noteId', [auth.checkApiAuthOrElectron], filesRoute.downloadFile);
|
||||
|
||||
apiRoute(GET, '/api/notes/:noteId/attributes', attributesRoute.getEffectiveNoteAttributes);
|
||||
apiRoute(PUT, '/api/notes/:noteId/attributes', attributesRoute.updateNoteAttributes);
|
||||
@@ -153,6 +155,7 @@ function register(app) {
|
||||
apiRoute(GET, '/api/options', optionsApiRoute.getOptions);
|
||||
apiRoute(PUT, '/api/options/:name/:value*', optionsApiRoute.updateOption);
|
||||
apiRoute(PUT, '/api/options', optionsApiRoute.updateOptions);
|
||||
apiRoute(GET, '/api/options/user-themes', optionsApiRoute.getUserThemes);
|
||||
|
||||
apiRoute(POST, '/api/password/change', passwordApiRoute.changePassword);
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ const BUILTIN_ATTRIBUTES = [
|
||||
{ type: 'label', name: 'manualTransactionHandling' },
|
||||
{ type: 'label', name: 'disableInclusion' },
|
||||
{ type: 'label', name: 'appCss' },
|
||||
{ type: 'label', name: 'appTheme' },
|
||||
{ type: 'label', name: 'hideChildrenOverview' },
|
||||
{ type: 'label', name: 'hidePromotedAttributes' },
|
||||
{ type: 'label', name: 'readOnly' },
|
||||
{ type: 'label', name: 'customRequestHandler' },
|
||||
{ type: 'label', name: 'customResourceProvider' },
|
||||
|
||||
// relation names
|
||||
{ type: 'relation', name: 'runOnNoteView' },
|
||||
@@ -43,6 +47,13 @@ async function getNotesWithLabel(name, value) {
|
||||
WHERE notes.isDeleted = 0 AND attributes.isDeleted = 0 AND attributes.name = ? ${valueCondition} ORDER BY position`, params);
|
||||
}
|
||||
|
||||
async function getNotesWithLabels(names) {
|
||||
const questionMarks = names.map(() => "?").join(", ");
|
||||
|
||||
return await repository.getEntities(`SELECT notes.* FROM notes JOIN attributes USING(noteId)
|
||||
WHERE notes.isDeleted = 0 AND attributes.isDeleted = 0 AND attributes.name IN (${questionMarks}) ORDER BY position`, names);
|
||||
}
|
||||
|
||||
async function getNoteWithLabel(name, value) {
|
||||
const notes = await getNotesWithLabel(name, value);
|
||||
|
||||
@@ -85,6 +96,7 @@ async function getAttributeNames(type, nameLike) {
|
||||
|
||||
module.exports = {
|
||||
getNotesWithLabel,
|
||||
getNotesWithLabels,
|
||||
getNoteWithLabel,
|
||||
createLabel,
|
||||
createAttribute,
|
||||
|
||||
@@ -19,13 +19,17 @@ const appInfo = require('./app_info');
|
||||
* @constructor
|
||||
* @hideconstructor
|
||||
*/
|
||||
function BackendScriptApi(startNote, currentNote, originEntity) {
|
||||
function BackendScriptApi(currentNote, apiParams) {
|
||||
/** @property {Note} note where script started executing */
|
||||
this.startNote = startNote;
|
||||
this.startNote = apiParams.startNote;
|
||||
/** @property {Note} note where script is currently executing */
|
||||
this.currentNote = currentNote;
|
||||
/** @property {Entity} entity whose event triggered this executions */
|
||||
this.originEntity = originEntity;
|
||||
this.originEntity = apiParams.originEntity;
|
||||
|
||||
for (const key in apiParams) {
|
||||
this[key] = apiParams[key];
|
||||
}
|
||||
|
||||
this.axios = axios;
|
||||
|
||||
@@ -169,6 +173,23 @@ function BackendScriptApi(startNote, currentNote, originEntity) {
|
||||
*/
|
||||
this.createNote = noteService.createNote;
|
||||
|
||||
/**
|
||||
* Creates new note according to given params and force all connected clients to refresh their tree.
|
||||
*
|
||||
* @method
|
||||
*
|
||||
* @param {string} parentNoteId - create new note under this parent
|
||||
* @param {string} title
|
||||
* @param {string} [content=""]
|
||||
* @param {CreateNoteExtraOptions} [extraOptions={}]
|
||||
* @returns {Promise<{note: Note, branch: Branch}>} object contains newly created entities note and branch
|
||||
*/
|
||||
this.createNoteAndRefresh = async function(parentNoteId, title, content, extraOptions) {
|
||||
await noteService.createNote(parentNoteId, title, content, extraOptions);
|
||||
|
||||
messagingService.refreshTree();
|
||||
};
|
||||
|
||||
/**
|
||||
* Log given message to trilium logs.
|
||||
*
|
||||
@@ -234,7 +255,7 @@ function BackendScriptApi(startNote, currentNote, originEntity) {
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
this.refreshTree = () => messagingService.sendMessageToAllClients({ type: 'refresh-tree' });
|
||||
this.refreshTree = messagingService.refreshTree;
|
||||
|
||||
/**
|
||||
* @return {{syncVersion, appVersion, buildRevision, dbVersion, dataDirectory, buildDate}|*} - object representing basic info about running Trilium version
|
||||
|
||||
@@ -1 +1 @@
|
||||
module.exports = { buildDate:"2019-01-22T23:01:32+01:00", buildRevision: "2ac560c56e2d347fccc0ad51b8d62999408a7f74" };
|
||||
module.exports = { buildDate:"2019-02-03T15:44:19+01:00", buildRevision: "afd5f4823f1f605300f906a61e8822e857b9ee5f" };
|
||||
|
||||
@@ -5,23 +5,39 @@ const sqlInit = require('./sql_init');
|
||||
const log = require('./log');
|
||||
const messagingService = require('./messaging');
|
||||
const syncMutexService = require('./sync_mutex');
|
||||
const repository = require('./repository.js');
|
||||
const repository = require('./repository');
|
||||
const cls = require('./cls');
|
||||
const syncTableService = require('./sync_table');
|
||||
const Branch = require('../entities/branch');
|
||||
|
||||
async function runCheck(query, errorText, errorList) {
|
||||
const result = await sql.getColumn(query);
|
||||
let unrecoverableConsistencyErrors = false;
|
||||
let fixedIssues = false;
|
||||
|
||||
if (result.length > 0) {
|
||||
const resultText = result.map(val => "'" + val + "'").join(', ');
|
||||
async function findIssues(query, errorCb) {
|
||||
const results = await sql.getRows(query);
|
||||
|
||||
const err = errorText + ": " + resultText;
|
||||
errorList.push(err);
|
||||
for (const res of results) {
|
||||
logError(errorCb(res));
|
||||
|
||||
log.error(err);
|
||||
unrecoverableConsistencyErrors = true;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function checkTreeCycles(errorList) {
|
||||
async function findAndFixIssues(query, fixerCb) {
|
||||
const results = await sql.getRows(query);
|
||||
|
||||
for (const res of results) {
|
||||
await fixerCb(res);
|
||||
|
||||
fixedIssues = true;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function checkTreeCycles() {
|
||||
const childToParents = {};
|
||||
const rows = await sql.getRows("SELECT noteId, parentNoteId FROM branches WHERE isDeleted = 0");
|
||||
|
||||
@@ -33,25 +49,29 @@ async function checkTreeCycles(errorList) {
|
||||
childToParents[childNoteId].push(parentNoteId);
|
||||
}
|
||||
|
||||
function checkTreeCycle(noteId, path, errorList) {
|
||||
function checkTreeCycle(noteId, path) {
|
||||
if (noteId === 'root') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!childToParents[noteId] || childToParents[noteId].length === 0) {
|
||||
errorList.push(`No parents found for noteId=${noteId}`);
|
||||
logError(`No parents found for note ${noteId}`);
|
||||
|
||||
unrecoverableConsistencyErrors = true;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const parentNoteId of childToParents[noteId]) {
|
||||
if (path.includes(parentNoteId)) {
|
||||
errorList.push(`Tree cycle detected at parent-child relationship: ${parentNoteId} - ${noteId}, whole path: ${path}`);
|
||||
logError(`Tree cycle detected at parent-child relationship: ${parentNoteId} - ${noteId}, whole path: ${path}`);
|
||||
|
||||
unrecoverableConsistencyErrors = true;
|
||||
}
|
||||
else {
|
||||
const newPath = path.slice();
|
||||
newPath.push(noteId);
|
||||
|
||||
checkTreeCycle(parentNoteId, newPath, errorList);
|
||||
checkTreeCycle(parentNoteId, newPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,279 +79,364 @@ async function checkTreeCycles(errorList) {
|
||||
const noteIds = Object.keys(childToParents);
|
||||
|
||||
for (const noteId of noteIds) {
|
||||
checkTreeCycle(noteId, [], errorList);
|
||||
checkTreeCycle(noteId, []);
|
||||
}
|
||||
|
||||
if (childToParents['root'].length !== 1 || childToParents['root'][0] !== 'none') {
|
||||
errorList.push('Incorrect root parent: ' + JSON.stringify(childToParents['root']));
|
||||
logError('Incorrect root parent: ' + JSON.stringify(childToParents['root']));
|
||||
unrecoverableConsistencyErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSyncRowChecks(table, key, errorList) {
|
||||
await runCheck(`
|
||||
SELECT
|
||||
${key}
|
||||
FROM
|
||||
${table}
|
||||
LEFT JOIN sync ON sync.entityName = '${table}' AND entityId = ${key}
|
||||
WHERE
|
||||
sync.id IS NULL AND ` + (table === 'options' ? 'isSynced = 1' : '1'),
|
||||
`Missing sync records for ${key} in table ${table}`, errorList);
|
||||
async function findBrokenReferenceIssues() {
|
||||
await findIssues(`
|
||||
SELECT branchId, branches.noteId
|
||||
FROM branches LEFT JOIN notes USING(noteId)
|
||||
WHERE notes.noteId IS NULL`,
|
||||
({branchId, noteId}) => `Branch ${branchId} references missing note ${noteId}`);
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
entityId
|
||||
FROM
|
||||
sync
|
||||
LEFT JOIN ${table} ON entityId = ${key}
|
||||
WHERE
|
||||
sync.entityName = '${table}'
|
||||
AND ${key} IS NULL`,
|
||||
`Missing ${table} records for existing sync rows`, errorList);
|
||||
await findIssues(`
|
||||
SELECT branchId, branches.noteId AS parentNoteId
|
||||
FROM branches LEFT JOIN notes ON notes.noteId = branches.parentNoteId
|
||||
WHERE branches.branchId != 'root' AND notes.noteId IS NULL`,
|
||||
({branchId, noteId}) => `Branch ${branchId} references missing parent note ${noteId}`);
|
||||
|
||||
await findIssues(`
|
||||
SELECT attributeId, attributes.noteId
|
||||
FROM attributes LEFT JOIN notes USING(noteId)
|
||||
WHERE notes.noteId IS NULL`,
|
||||
({attributeId, noteId}) => `Attribute ${attributeId} references missing source note ${noteId}`);
|
||||
|
||||
// empty targetNoteId for relations is a special fixable case so not covered here
|
||||
await findIssues(`
|
||||
SELECT attributeId, attributes.noteId
|
||||
FROM attributes LEFT JOIN notes ON notes.noteId = attributes.value
|
||||
WHERE attributes.type = 'relation' AND attributes.value != '' AND notes.noteId IS NULL`,
|
||||
({attributeId, noteId}) => `Relation ${attributeId} references missing note ${noteId}`);
|
||||
|
||||
await findIssues(`
|
||||
SELECT linkId, links.noteId
|
||||
FROM links LEFT JOIN notes USING(noteId)
|
||||
WHERE notes.noteId IS NULL`,
|
||||
({linkId, noteId}) => `Link ${linkId} references missing source note ${noteId}`);
|
||||
|
||||
await findIssues(`
|
||||
SELECT linkId, links.noteId
|
||||
FROM links LEFT JOIN notes ON notes.noteId = links.targetNoteId
|
||||
WHERE notes.noteId IS NULL`,
|
||||
({linkId, noteId}) => `Link ${linkId} references missing target note ${noteId}`);
|
||||
|
||||
await findIssues(`
|
||||
SELECT noteRevisionId, note_revisions.noteId
|
||||
FROM note_revisions LEFT JOIN notes USING(noteId)
|
||||
WHERE notes.noteId IS NULL`,
|
||||
({noteRevisionId, noteId}) => `Note revision ${noteRevisionId} references missing note ${noteId}`);
|
||||
}
|
||||
|
||||
async function fixEmptyRelationTargets(errorList) {
|
||||
const emptyRelations = await repository.getEntities("SELECT * FROM attributes WHERE isDeleted = 0 AND type = 'relation' AND value = ''");
|
||||
async function findExistencyIssues() {
|
||||
// principle for fixing inconsistencies is that if the note itself is deleted (isDeleted=true) then all related entities should be also deleted (branches, links, attributes)
|
||||
// but if note is not deleted, then at least one branch should exist.
|
||||
|
||||
for (const relation of emptyRelations) {
|
||||
relation.isDeleted = true;
|
||||
await relation.save();
|
||||
// the order here is important - first we might need to delete inconsistent branches and after that
|
||||
// another check might create missing branch
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
branchId, noteId
|
||||
FROM
|
||||
branches
|
||||
JOIN notes USING(noteId)
|
||||
WHERE
|
||||
notes.isDeleted = 1
|
||||
AND branches.isDeleted = 0`,
|
||||
async ({branchId, noteId}) => {
|
||||
const branch = await repository.getBranch(branchId);
|
||||
branch.isDeleted = true;
|
||||
await branch.save();
|
||||
|
||||
errorList.push(`Relation ${relation.attributeId} of name "${relation.name} has empty target. Autofixed.`);
|
||||
}
|
||||
}
|
||||
logFix(`Branch ${branchId} has been deleted since associated note ${noteId} is deleted.`);
|
||||
});
|
||||
|
||||
async function fixUndeletedBranches() {
|
||||
const undeletedBranches = await sql.getRows(`
|
||||
SELECT
|
||||
branchId, noteId
|
||||
FROM
|
||||
branches
|
||||
JOIN notes USING(noteId)
|
||||
WHERE
|
||||
notes.isDeleted = 1
|
||||
AND branches.isDeleted = 0`);
|
||||
|
||||
for (const {branchId, noteId} of undeletedBranches) {
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
branchId, parentNoteId
|
||||
FROM
|
||||
branches
|
||||
JOIN notes AS parentNote ON parentNote.noteId = branches.parentNoteId
|
||||
WHERE
|
||||
parentNote.isDeleted = 1
|
||||
AND branches.isDeleted = 0
|
||||
`, async ({branchId, parentNoteId}) => {
|
||||
const branch = await repository.getBranch(branchId);
|
||||
branch.isDeleted = true;
|
||||
await branch.save();
|
||||
|
||||
log.info(`Branch ${branchId} has been deleted since associated note ${noteId} is deleted.`);
|
||||
}
|
||||
logFix(`Branch ${branchId} has been deleted since associated parent note ${parentNoteId} is deleted.`);
|
||||
});
|
||||
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
DISTINCT notes.noteId
|
||||
FROM
|
||||
notes
|
||||
LEFT JOIN branches ON notes.noteId = branches.noteId AND branches.isDeleted = 0
|
||||
WHERE
|
||||
notes.isDeleted = 0
|
||||
AND branches.branchId IS NULL
|
||||
`, async ({noteId}) => {
|
||||
const branch = await new Branch({
|
||||
parentNoteId: 'root',
|
||||
noteId: noteId,
|
||||
prefix: 'recovered'
|
||||
}).save();
|
||||
|
||||
logFix(`Created missing branch ${branch.branchId} for note ${noteId}`);
|
||||
});
|
||||
|
||||
// there should be a unique relationship between note and its parent
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
noteId, parentNoteId
|
||||
FROM
|
||||
branches
|
||||
WHERE
|
||||
branches.isDeleted = 0
|
||||
GROUP BY
|
||||
branches.parentNoteId,
|
||||
branches.noteId
|
||||
HAVING
|
||||
COUNT(*) > 1`,
|
||||
async ({noteId, parentNoteId}) => {
|
||||
const branches = await repository.getEntities(`SELECT * FROM branches WHERE noteId = ? and parentNoteId = ? and isDeleted = 1`, [noteId, parentNoteId]);
|
||||
|
||||
// it's not necessarily "original" branch, it's just the only one which will survive
|
||||
const origBranch = branches.get(0);
|
||||
|
||||
// delete all but the first branch
|
||||
for (const branch of branches.slice(1)) {
|
||||
branch.isDeleted = true;
|
||||
await branch.save();
|
||||
|
||||
logFix(`Removing branch ${branch.branchId} since it's parent-child duplicate of branch ${origBranch.branchId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runAllChecks() {
|
||||
const errorList = [];
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
noteId
|
||||
FROM
|
||||
notes
|
||||
LEFT JOIN branches USING(noteId)
|
||||
WHERE
|
||||
noteId != 'root'
|
||||
AND branches.branchId IS NULL`,
|
||||
"Missing branches records for following note IDs", errorList);
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
branchId || ' > ' || branches.noteId
|
||||
FROM
|
||||
branches
|
||||
LEFT JOIN notes USING(noteId)
|
||||
WHERE
|
||||
notes.noteId IS NULL`,
|
||||
"Missing notes records for following branch ID > note ID", errorList);
|
||||
|
||||
await fixUndeletedBranches();
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
child.branchId
|
||||
FROM
|
||||
branches AS child
|
||||
WHERE
|
||||
child.isDeleted = 0
|
||||
AND child.parentNoteId != 'none'
|
||||
AND (SELECT COUNT(*) FROM branches AS parent WHERE parent.noteId = child.parentNoteId
|
||||
AND parent.isDeleted = 0) = 0`,
|
||||
"All parent branches are deleted but child branch is not for these child branch IDs", errorList);
|
||||
|
||||
// we do extra JOIN to eliminate orphan notes without branches (which are reported separately)
|
||||
await runCheck(`
|
||||
SELECT
|
||||
DISTINCT noteId
|
||||
FROM
|
||||
notes
|
||||
JOIN branches USING(noteId)
|
||||
async function findLogicIssues() {
|
||||
await findIssues( `
|
||||
SELECT noteId, type
|
||||
FROM notes
|
||||
WHERE
|
||||
(SELECT COUNT(*) FROM branches WHERE notes.noteId = branches.noteId AND branches.isDeleted = 0) = 0
|
||||
AND notes.isDeleted = 0
|
||||
`, 'No undeleted branches for note IDs', errorList);
|
||||
isDeleted = 0
|
||||
AND type NOT IN ('text', 'code', 'render', 'file', 'image', 'search', 'relation-map')`,
|
||||
({noteId, type}) => `Note ${noteId} has invalid type=${type}`);
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
child.parentNoteId || ' > ' || child.noteId
|
||||
FROM branches
|
||||
AS child
|
||||
LEFT JOIN branches AS parent ON parent.noteId = child.parentNoteId
|
||||
WHERE
|
||||
parent.noteId IS NULL
|
||||
AND child.parentNoteId != 'none'`,
|
||||
"Not existing parent in the following parent > child relations", errorList);
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
noteRevisionId || ' > ' || note_revisions.noteId
|
||||
FROM
|
||||
note_revisions LEFT JOIN notes USING(noteId)
|
||||
WHERE
|
||||
notes.noteId IS NULL`,
|
||||
"Missing notes records for following note revision ID > note ID", errorList);
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
branches.parentNoteId || ' > ' || branches.noteId
|
||||
FROM
|
||||
branches
|
||||
WHERE
|
||||
branches.isDeleted = 0
|
||||
GROUP BY
|
||||
branches.parentNoteId,
|
||||
branches.noteId
|
||||
HAVING
|
||||
COUNT(*) > 1`,
|
||||
"Duplicate undeleted parent note <-> note relationship - parent note ID > note ID", errorList);
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
noteId
|
||||
FROM
|
||||
notes
|
||||
WHERE
|
||||
type != 'text'
|
||||
AND type != 'code'
|
||||
AND type != 'render'
|
||||
AND type != 'file'
|
||||
AND type != 'image'
|
||||
AND type != 'search'
|
||||
AND type != 'relation-map'`,
|
||||
"Note has invalid type", errorList);
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
noteId
|
||||
FROM
|
||||
notes
|
||||
await findIssues(`
|
||||
SELECT noteId
|
||||
FROM notes
|
||||
WHERE
|
||||
isDeleted = 0
|
||||
AND content IS NULL`,
|
||||
"Note content is null even though it is not deleted", errorList);
|
||||
({noteId}) => `Note ${noteId} content is null even though it is not deleted`);
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
parentNoteId
|
||||
await findIssues(`
|
||||
SELECT parentNoteId
|
||||
FROM
|
||||
branches
|
||||
JOIN notes ON notes.noteId = branches.parentNoteId
|
||||
WHERE
|
||||
notes.isDeleted = 0
|
||||
AND notes.type == 'search'
|
||||
AND branches.isDeleted = 0`,
|
||||
({parentNoteId}) => `Search note ${parentNoteId} has children`);
|
||||
|
||||
await findAndFixIssues(`
|
||||
SELECT attributeId
|
||||
FROM attributes
|
||||
WHERE
|
||||
type == 'search'`,
|
||||
"Search note has children", errorList);
|
||||
isDeleted = 0
|
||||
AND type = 'relation'
|
||||
AND value = ''`,
|
||||
async ({attributeId}) => {
|
||||
const relation = await repository.getAttribute(attributeId);
|
||||
relation.isDeleted = true;
|
||||
await relation.save();
|
||||
|
||||
await fixEmptyRelationTargets(errorList);
|
||||
logFix(`Removed relation ${relation.attributeId} of name "${relation.name} with empty target.`);
|
||||
});
|
||||
|
||||
await runCheck(`
|
||||
await findIssues(`
|
||||
SELECT
|
||||
attributeId
|
||||
FROM
|
||||
attributes
|
||||
attributeId,
|
||||
type
|
||||
FROM attributes
|
||||
WHERE
|
||||
type != 'label'
|
||||
isDeleted = 0
|
||||
AND type != 'label'
|
||||
AND type != 'label-definition'
|
||||
AND type != 'relation'
|
||||
AND type != 'relation-definition'`,
|
||||
"Attribute has invalid type", errorList);
|
||||
({attributeId, type}) => `Attribute ${attributeId} has invalid type '${type}'`);
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
attributeId
|
||||
FROM
|
||||
attributes
|
||||
LEFT JOIN notes ON attributes.noteId = notes.noteId AND notes.isDeleted = 0
|
||||
WHERE
|
||||
attributes.isDeleted = 0
|
||||
AND notes.noteId IS NULL`,
|
||||
"Attribute reference to the owning note is broken", errorList);
|
||||
|
||||
await runCheck(`
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
attributeId
|
||||
attributeId,
|
||||
attributes.noteId
|
||||
FROM
|
||||
attributes
|
||||
LEFT JOIN notes AS targetNote ON attributes.value = targetNote.noteId AND targetNote.isDeleted = 0
|
||||
JOIN notes ON attributes.noteId = notes.noteId
|
||||
WHERE
|
||||
attributes.isDeleted = 0
|
||||
AND notes.isDeleted = 1`,
|
||||
async ({attributeId, noteId}) => {
|
||||
const attribute = await repository.getAttribute(attributeId);
|
||||
attribute.isDeleted = true;
|
||||
await attribute.save();
|
||||
|
||||
logFix(`Removed attribute ${attributeId} because owning note ${noteId} is also deleted.`);
|
||||
});
|
||||
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
attributeId,
|
||||
attributes.value AS targetNoteId
|
||||
FROM
|
||||
attributes
|
||||
JOIN notes ON attributes.value = notes.noteId
|
||||
WHERE
|
||||
attributes.type = 'relation'
|
||||
AND attributes.isDeleted = 0
|
||||
AND targetNote.noteId IS NULL`,
|
||||
"Relation reference to the target note is broken", errorList);
|
||||
AND notes.isDeleted = 1`,
|
||||
async ({attributeId, targetNoteId}) => {
|
||||
const attribute = await repository.getAttribute(attributeId);
|
||||
attribute.isDeleted = true;
|
||||
await attribute.save();
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
linkId
|
||||
FROM
|
||||
links
|
||||
WHERE
|
||||
type != 'image'
|
||||
AND type != 'hyper'
|
||||
AND type != 'relation-map'`,
|
||||
"Link type is invalid", errorList);
|
||||
logFix(`Removed attribute ${attributeId} because target note ${targetNoteId} is also deleted.`);
|
||||
});
|
||||
|
||||
await runCheck(`
|
||||
SELECT
|
||||
linkId
|
||||
FROM
|
||||
await findIssues(`
|
||||
SELECT linkId
|
||||
FROM links
|
||||
WHERE type NOT IN ('image', 'hyper', 'relation-map')`,
|
||||
({linkId, type}) => `Link ${linkId} has invalid type '${type}'`);
|
||||
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
linkId,
|
||||
links.noteId AS sourceNoteId
|
||||
FROM
|
||||
links
|
||||
LEFT JOIN notes AS sourceNote ON sourceNote.noteId = links.noteId AND sourceNote.isDeleted = 0
|
||||
LEFT JOIN notes AS targetNote ON targetNote.noteId = links.noteId AND targetNote.isDeleted = 0
|
||||
WHERE
|
||||
JOIN notes AS sourceNote ON sourceNote.noteId = links.noteId
|
||||
WHERE
|
||||
links.isDeleted = 0
|
||||
AND (sourceNote.noteId IS NULL
|
||||
OR targetNote.noteId IS NULL)`,
|
||||
"Link to source/target note link is broken", errorList);
|
||||
AND sourceNote.isDeleted = 1`,
|
||||
async ({linkId, sourceNoteId}) => {
|
||||
const link = await repository.getLink(linkId);
|
||||
link.isDeleted = true;
|
||||
await link.save();
|
||||
|
||||
await runSyncRowChecks("notes", "noteId", errorList);
|
||||
await runSyncRowChecks("note_revisions", "noteRevisionId", errorList);
|
||||
await runSyncRowChecks("branches", "branchId", errorList);
|
||||
await runSyncRowChecks("recent_notes", "branchId", errorList);
|
||||
await runSyncRowChecks("attributes", "attributeId", errorList);
|
||||
await runSyncRowChecks("api_tokens", "apiTokenId", errorList);
|
||||
await runSyncRowChecks("options", "name", errorList);
|
||||
logFix(`Removed link ${linkId} because source note ${sourceNoteId} is also deleted.`);
|
||||
});
|
||||
|
||||
if (errorList.length === 0) {
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
linkId,
|
||||
links.targetNoteId
|
||||
FROM
|
||||
links
|
||||
JOIN notes AS targetNote ON targetNote.noteId = links.targetNoteId
|
||||
WHERE
|
||||
links.isDeleted = 0
|
||||
AND targetNote.isDeleted = 1`,
|
||||
async ({linkId, targetNoteId}) => {
|
||||
const link = await repository.getLink(linkId);
|
||||
link.isDeleted = true;
|
||||
await link.save();
|
||||
|
||||
logFix(`Removed link ${linkId} because target note ${targetNoteId} is also deleted.`);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSyncRowChecks(entityName, key) {
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
${key} as entityId
|
||||
FROM
|
||||
${entityName}
|
||||
LEFT JOIN sync ON sync.entityName = '${entityName}' AND entityId = ${key}
|
||||
WHERE
|
||||
sync.id IS NULL AND ` + (entityName === 'options' ? 'isSynced = 1' : '1'),
|
||||
async ({entityId}) => {
|
||||
await syncTableService.addEntitySync(entityName, entityId);
|
||||
|
||||
logFix(`Created missing sync record entityName=${entityName}, entityId=${entityId}`);
|
||||
});
|
||||
|
||||
await findAndFixIssues(`
|
||||
SELECT
|
||||
id, entityId
|
||||
FROM
|
||||
sync
|
||||
LEFT JOIN ${entityName} ON entityId = ${key}
|
||||
WHERE
|
||||
sync.entityName = '${entityName}'
|
||||
AND ${key} IS NULL`,
|
||||
async ({id, entityId}) => {
|
||||
|
||||
await sql.execute("DELETE FROM sync WHERE entityName = ? AND entityId = ?", [entityName, entityId]);
|
||||
|
||||
logFix(`Deleted extra sync record id=${id}, entityName=${entityName}, entityId=${entityId}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function findSyncRowsIssues() {
|
||||
await runSyncRowChecks("notes", "noteId");
|
||||
await runSyncRowChecks("note_revisions", "noteRevisionId");
|
||||
await runSyncRowChecks("branches", "branchId");
|
||||
await runSyncRowChecks("recent_notes", "branchId");
|
||||
await runSyncRowChecks("attributes", "attributeId");
|
||||
await runSyncRowChecks("api_tokens", "apiTokenId");
|
||||
await runSyncRowChecks("options", "name");
|
||||
}
|
||||
|
||||
async function runAllChecks() {
|
||||
unrecoverableConsistencyErrors = false;
|
||||
fixedIssues = false;
|
||||
|
||||
await findBrokenReferenceIssues();
|
||||
|
||||
await findExistencyIssues();
|
||||
|
||||
await findLogicIssues();
|
||||
|
||||
await findSyncRowsIssues();
|
||||
|
||||
if (unrecoverableConsistencyErrors) {
|
||||
// we run this only if basic checks passed since this assumes basic data consistency
|
||||
|
||||
await checkTreeCycles(errorList);
|
||||
await checkTreeCycles();
|
||||
}
|
||||
|
||||
return errorList;
|
||||
return !unrecoverableConsistencyErrors;
|
||||
}
|
||||
|
||||
async function runChecks() {
|
||||
let errorList;
|
||||
let elapsedTimeMs;
|
||||
|
||||
await syncMutexService.doExclusively(async () => {
|
||||
const startTime = new Date();
|
||||
|
||||
errorList = await runAllChecks();
|
||||
await runAllChecks();
|
||||
|
||||
elapsedTimeMs = new Date().getTime() - startTime.getTime();
|
||||
});
|
||||
|
||||
if (errorList.length > 0) {
|
||||
log.info(`Consistency checks failed (took ${elapsedTimeMs}ms) with these errors: ` + JSON.stringify(errorList));
|
||||
if (fixedIssues) {
|
||||
messagingService.refreshTree();
|
||||
}
|
||||
|
||||
if (unrecoverableConsistencyErrors) {
|
||||
log.info(`Consistency checks failed (took ${elapsedTimeMs}ms)`);
|
||||
|
||||
messagingService.sendMessageToAllClients({type: 'consistency-checks-failed'});
|
||||
}
|
||||
@@ -340,13 +445,19 @@ async function runChecks() {
|
||||
}
|
||||
}
|
||||
|
||||
function logFix(message) {
|
||||
log.info("Consistency issue fixed: " + message);
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
log.info("Consistency error: " + message);
|
||||
}
|
||||
|
||||
sqlInit.dbReady.then(() => {
|
||||
setInterval(cls.wrap(runChecks), 60 * 60 * 1000);
|
||||
|
||||
// kickoff backup immediately
|
||||
setTimeout(cls.wrap(runChecks), 10000);
|
||||
// kickoff checks soon after startup (to not block the initial load)
|
||||
setTimeout(cls.wrap(runChecks), 10 * 1000);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
runChecks
|
||||
};
|
||||
module.exports = {};
|
||||
@@ -20,6 +20,10 @@ async function exportSingleNote(branch, format, res) {
|
||||
|
||||
if (note.type === 'text') {
|
||||
if (format === 'html') {
|
||||
if (!note.content.toLowerCase().includes("<html")) {
|
||||
note.content = '<html><head><meta charset="utf-8"></head><body>' + note.content + '</body></html>';
|
||||
}
|
||||
|
||||
payload = html.prettyPrint(note.content, {indent_size: 2});
|
||||
extension = 'html';
|
||||
mime = 'text/html';
|
||||
|
||||
@@ -74,11 +74,10 @@ async function exportToTar(branch, format, res) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseFileName = branch.prefix ? (branch.prefix + ' - ' + note.title) : note.title;
|
||||
const baseFileName = sanitize(branch.prefix ? (branch.prefix + ' - ' + note.title) : note.title);
|
||||
|
||||
if (note.noteId in noteIdToMeta) {
|
||||
const sanitizedFileName = sanitize(baseFileName + ".clone");
|
||||
const fileName = getUniqueFilename(existingFileNames, sanitizedFileName);
|
||||
const fileName = getUniqueFilename(existingFileNames, baseFileName + ".clone");
|
||||
|
||||
return {
|
||||
isClone: true,
|
||||
@@ -150,6 +149,10 @@ async function exportToTar(branch, format, res) {
|
||||
|
||||
function prepareContent(note, format) {
|
||||
if (format === 'html') {
|
||||
if (!note.content.toLowerCase().includes("<html")) {
|
||||
note.content = '<html><head><meta charset="utf-8"></head><body>' + note.content + '</body></html>';
|
||||
}
|
||||
|
||||
return html.prettyPrint(note.content, {indent_size: 2});
|
||||
}
|
||||
else if (format === 'markdown') {
|
||||
|
||||
@@ -12,7 +12,7 @@ async function runAttachedRelations(note, relationName, originEntity) {
|
||||
const scriptNote = await relation.getTargetNote();
|
||||
|
||||
if (scriptNote) {
|
||||
await scriptService.executeNote(scriptNote, originEntity);
|
||||
await scriptService.executeNoteNoException(scriptNote, { originEntity });
|
||||
}
|
||||
else {
|
||||
log.error(`Target note ${relation.value} of atttribute ${relation.attributeId} has not been found.`);
|
||||
@@ -30,7 +30,7 @@ eventService.subscribe(eventService.NOTE_TITLE_CHANGED, async note => {
|
||||
if (await parent.hasLabel("sorted")) {
|
||||
await treeService.sortNotesAlphabetically(parent.noteId);
|
||||
|
||||
messagingService.sendMessageToAllClients({ type: 'refresh-tree' });
|
||||
messagingService.refreshTree();
|
||||
break; // sending the message once is enough
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ async function sendMessage(client, message) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTree() {
|
||||
await sendMessageToAllClients({ type: 'refresh-tree' });
|
||||
}
|
||||
|
||||
async function sendMessageToAllClients(message) {
|
||||
const jsonStr = JSON.stringify(message);
|
||||
|
||||
@@ -76,5 +80,6 @@ async function sendPing(client, lastSentSyncId) {
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
refreshTree,
|
||||
sendMessageToAllClients
|
||||
};
|
||||
@@ -57,6 +57,11 @@ async function getOption(name) {
|
||||
return await getEntity("SELECT * FROM options WHERE name = ?", [name]);
|
||||
}
|
||||
|
||||
/** @returns {Link|null} */
|
||||
async function getLink(linkId) {
|
||||
return await getEntity("SELECT * FROM links WHERE linkId = ?", [linkId]);
|
||||
}
|
||||
|
||||
async function updateEntity(entity) {
|
||||
const entityName = entity.constructor.entityName;
|
||||
const primaryKeyName = entity.constructor.primaryKeyName;
|
||||
@@ -119,6 +124,7 @@ module.exports = {
|
||||
getBranch,
|
||||
getAttribute,
|
||||
getOption,
|
||||
getLink,
|
||||
updateEntity,
|
||||
setEntityConstructor
|
||||
};
|
||||
@@ -17,7 +17,7 @@ async function runNotesWithLabel(runAttrValue) {
|
||||
AND notes.isDeleted = 0`, [runAttrValue]);
|
||||
|
||||
for (const note of notes) {
|
||||
scriptService.executeNote(note, note);
|
||||
scriptService.executeNoteNoException(note, { originEntity: note });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,37 +5,48 @@ const cls = require('./cls');
|
||||
const sourceIdService = require('./source_id');
|
||||
const log = require('./log');
|
||||
|
||||
async function executeNote(note, originEntity) {
|
||||
async function executeNote(note, apiParams) {
|
||||
if (!note.isJavaScript() || note.getScriptEnv() !== 'backend' || !note.isContentAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bundle = await getScriptBundle(note);
|
||||
|
||||
await executeBundle(bundle, note, originEntity);
|
||||
await executeBundle(bundle, apiParams);
|
||||
}
|
||||
|
||||
async function executeBundle(bundle, startNote, originEntity = null) {
|
||||
if (!startNote) {
|
||||
async function executeNoteNoException(note, apiParams) {
|
||||
try {
|
||||
await executeNote(note, apiParams);
|
||||
}
|
||||
catch (e) {
|
||||
// just swallow, exception is logged already in executeNote
|
||||
}
|
||||
}
|
||||
|
||||
async function executeBundle(bundle, apiParams = {}) {
|
||||
if (!apiParams.startNote) {
|
||||
// this is the default case, the only exception is when we want to preserve frontend startNote
|
||||
startNote = bundle.note;
|
||||
apiParams.startNote = bundle.note;
|
||||
}
|
||||
|
||||
// last \r\n is necessary if script contains line comment on its last line
|
||||
const script = "async function() {\r\n" + bundle.script + "\r\n}";
|
||||
|
||||
const ctx = new ScriptContext(startNote, bundle.allNotes, originEntity);
|
||||
const ctx = new ScriptContext(bundle.allNotes, apiParams);
|
||||
|
||||
try {
|
||||
if (await bundle.note.hasLabel('manualTransactionHandling')) {
|
||||
return await execute(ctx, script, '');
|
||||
return await execute(ctx, script);
|
||||
}
|
||||
else {
|
||||
return await sql.transactional(async () => await execute(ctx, script, ''));
|
||||
return await sql.transactional(async () => await execute(ctx, script));
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
log.error(`Execution of script "${bundle.note.title}" (${bundle.note.noteId}) failed with error: ${e.message}`);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +68,11 @@ async function executeScript(script, params, startNoteId, currentNoteId, originE
|
||||
return await executeBundle(bundle, startNote, originEntity);
|
||||
}
|
||||
|
||||
async function execute(ctx, script, paramsStr) {
|
||||
async function execute(ctx, script, params = []) {
|
||||
// scripts run as "server" sourceId so clients recognize the changes as "foreign" and update themselves
|
||||
cls.namespace.set('sourceId', sourceIdService.getCurrentSourceId());
|
||||
|
||||
return await (function() { return eval(`const apiContext = this;\r\n(${script}\r\n)(${paramsStr})`); }.call(ctx));
|
||||
return await (function() { return eval(`const apiContext = this;\r\n(${script}\r\n)()`); }.call(ctx));
|
||||
}
|
||||
|
||||
function getParams(params) {
|
||||
@@ -168,6 +179,7 @@ function sanitizeVariableName(str) {
|
||||
|
||||
module.exports = {
|
||||
executeNote,
|
||||
executeNoteNoException,
|
||||
executeScript,
|
||||
getScriptBundleForFrontend
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
const utils = require('./utils');
|
||||
const BackendScriptApi = require('./backend_script_api');
|
||||
|
||||
function ScriptContext(startNote, allNotes, originEntity = null) {
|
||||
function ScriptContext(allNotes, apiParams = {}) {
|
||||
this.modules = {};
|
||||
this.notes = utils.toObject(allNotes, note => [note.noteId, note]);
|
||||
this.apis = utils.toObject(allNotes, note => [note.noteId, new BackendScriptApi(startNote, note, originEntity)]);
|
||||
this.apis = utils.toObject(allNotes, note => [note.noteId, new BackendScriptApi(note, apiParams)]);
|
||||
this.require = moduleNoteIds => {
|
||||
return moduleName => {
|
||||
const candidates = allNotes.filter(note => moduleNoteIds.includes(note.noteId));
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
<div id="search-box">
|
||||
<div style="display: flex; align-items: center; flex-wrap: wrap;">
|
||||
<input name="search-text" placeholder="Search text, labels" style="flex-grow: 100; margin-left: 5px; margin-right: 5px; flex-basis: 5em; min-width: 0;" autocomplete="off">
|
||||
<input name="search-text" id="search-text" placeholder="Search text, labels" style="flex-grow: 100; margin-left: 5px; margin-right: 5px; flex-basis: 5em; min-width: 0;" autocomplete="off">
|
||||
<button id="do-search-button" class="btn btn-sm icon-button jam jam-search" title="Search (enter)"></button>
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@
|
||||
maxSyncIdAtLoad: <%= maxSyncIdAtLoad %>,
|
||||
instanceName: '<%= instanceName %>'
|
||||
};
|
||||
window.appCssNoteIds = <%- JSON.stringify(appCssNoteIds) %>;
|
||||
</script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
@@ -247,9 +248,5 @@
|
||||
// final form which is pretty ugly.
|
||||
$("#container").show();
|
||||
</script>
|
||||
|
||||
<style type="text/css">
|
||||
<%= appCss %>
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<div id="note-detail-file" class="note-detail-component">
|
||||
<table id="file-table">
|
||||
<tr>
|
||||
<th>Note ID:</th>
|
||||
<td id="file-note-id"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Original file name:</th>
|
||||
<td id="file-filename"></td>
|
||||
@@ -19,7 +23,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<td colspan="2">
|
||||
<button id="file-download" class="btn btn-primary" type="button">Download</button>
|
||||
|
||||
<button id="file-open" class="btn btn-primary" type="button">Open</button>
|
||||
|
||||
@@ -40,11 +40,7 @@
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<label for="theme-select">Theme</label>
|
||||
<select class="form-control" id="theme-select">
|
||||
<option value="white">White</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="black">Black</option>
|
||||
</select>
|
||||
<select class="form-control" id="theme-select"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>Login</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/app-icons/ios/apple-touch-icon.png">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -71,7 +72,7 @@
|
||||
document.cookie = name + "=" + (value || "") + expires + "; path=/";
|
||||
}
|
||||
</script>
|
||||
|
||||
<link href="libraries/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
</body>
|
||||
|
||||
<link href="libraries/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>Trilium Notes</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/app-icons/ios/apple-touch-icon.png">
|
||||
</head>
|
||||
<body class="mobile">
|
||||
<div class="row" id="container-row" style="display: none;">
|
||||
@@ -101,7 +102,7 @@
|
||||
<script type="text/javascript">
|
||||
// we hide container initally because otherwise it is rendered first without CSS and then flickers into
|
||||
// final form which is pretty ugly.
|
||||
$("#container-row").show();
|
||||
</script>
|
||||
</body>
|
||||
$("#container-row").show();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
6
src/www
6
src/www
@@ -20,6 +20,12 @@ const messagingService = require('./services/messaging');
|
||||
const utils = require('./services/utils');
|
||||
const sqlInit = require('./services/sql_init');
|
||||
const port = require('./services/port');
|
||||
const semver = require('semver');
|
||||
|
||||
if (!semver.satisfies(process.version, ">=10.5.0")) {
|
||||
console.error("Trilium only supports node.js 10.5 and later");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let httpServer;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user