Compare commits

...

40 Commits

Author SHA1 Message Date
zadam
6cc0dd5a80 release 0.29.0-beta 2019-02-03 15:44:19 +01:00
zadam
afd5f4823f added Steel Blue theme to demo document 2019-02-03 15:39:27 +01:00
zadam
b0cf82c91b fix 2019-02-03 15:37:01 +01:00
zadam
6a67cdd5af appThemeClass is redundant 2019-02-03 15:35:37 +01:00
zadam
bad7b84993 error handling in custom request handler 2019-02-03 11:15:32 +01:00
zadam
d3ca6b5ae6 styling for scrollbar which looks ugly otherwise in dark themes 2019-02-03 10:21:28 +01:00
zadam
da5009f089 main border color CSS variable 2019-02-03 10:09:59 +01:00
zadam
c08524c977 fix CSS class of user theme 2019-02-03 00:12:57 +01:00
zadam
f89537037e small styling fixes 2019-02-02 23:51:00 +01:00
zadam
c153793766 added CSS variable for disabled button background 2019-02-02 23:26:39 +01:00
zadam
0aec5927d5 added missing labels customRequestHandler, customResourceProvider to autocomplete 2019-02-02 22:33:02 +01:00
zadam
8aea9a1801 added font family CSS variables to theming API 2019-02-02 20:45:38 +01:00
zadam
73247e3220 minor library updates 2019-02-02 19:21:30 +01:00
zadam
89344a6eda final fixes and refactorings for consistency checks 2019-02-02 12:41:20 +01:00
zadam
40d2e6ea83 refactoring consistency checks WIP 2019-02-02 11:26:27 +01:00
zadam
910cfe9a17 refactoring consistency checks WIP 2019-02-02 10:38:33 +01:00
zadam
e58a80fc00 consistency checks WIP 2019-02-02 09:26:57 +01:00
zadam
4a2319cb33 refactoring of consistency checks plus some autofixers 2019-02-01 22:48:51 +01:00
zadam
5619088c41 raise payload size limit to 500 MB #395 2019-01-29 21:19:08 +01:00
zadam
60271993eb Merge pull request #392 from jkurei/ios_favicon
Better icon for iOS' homescreen
2019-01-28 23:01:12 +01:00
jkurei
6695e8b011 Better icon for iOS' homescreen 2019-01-28 22:48:01 +01:00
zadam
707df18b93 added type and mime classes on body as well #383 2019-01-28 21:42:37 +01:00
zadam
90895f1288 added noteId to file view 2019-01-27 23:10:37 +01:00
zadam
ba1ca506af change in referencing CSS resources to allow easier relative linking 2019-01-27 22:34:41 +01:00
zadam
f90ed99a40 fix leaf node having angle bracket in dark & black themes, closes #387 2019-01-27 21:54:24 +01:00
zadam
67630b1a22 options now allow selecting user theme 2019-01-27 21:18:11 +01:00
zadam
2c1580ea65 appCss/appTheme are now loaded as external CSS files instead of inline styles 2019-01-27 17:01:37 +01:00
zadam
840a0b5f64 custom handler refactoring 2019-01-27 16:37:18 +01:00
zadam
b39f6ef7ad bug fixes for custom handlers 2019-01-27 15:47:40 +01:00
zadam
fb27088fcd smaller children overview font 2019-01-27 14:26:39 +01:00
azivner
76fbff68ba added readOnly attribute which puts text editor into readonly mode #371 2019-01-27 13:10:03 +01:00
azivner
54de4d236d custom HTTP handler which triggers associated script notes WIP, #356 2019-01-27 12:28:20 +01:00
azivner
e211dd65ad exit on detection of not supported node version, #324 2019-01-26 19:59:51 +01:00
azivner
a87f4d8653 search can be triggered from URL, closes #385 2019-01-25 22:18:34 +01:00
azivner
b59c175c2e add HTML header with UTF-8 meta encoding declaration to exported HTML files, fixes #384 2019-01-25 21:34:14 +01:00
azivner
580104c4c5 using trilium's confirm dialog, small refactoring 2019-01-24 22:18:31 +01:00
zadam
0fc3053b0a Merge pull request #377 from flurmbo/master
add confirm type change dialog when note not empty
2019-01-24 22:12:09 +01:00
Phil Marshall
929e0f69c2 add confirm type change dialog when note not empty 2019-01-23 15:15:24 -06:00
azivner
e70af1300a drag and drop moves multiple items only if CTRL is pressed, active note has now bold text for more differentiation from selected note 2019-01-23 21:13:04 +01:00
azivner
4d0e46021b Mac uses CMD+Left, CMD+Right for history navigation, closes #376 2019-01-23 20:15:33 +01:00
50 changed files with 2660 additions and 866 deletions

Binary file not shown.

2309
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
&nbsp;
@@ -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>

View File

@@ -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>
&nbsp;
<button id="file-open" class="btn btn-primary" type="button">Open</button>

View File

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

View File

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

View File

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

View File

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