Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1092c97b5 | |||
| a04becc4ec | |||
| f7d6bda49d | |||
| b250f0a3bf | |||
| df1d94ec61 | |||
| e00fcd93a1 | |||
| 0a95d0f6f5 | |||
| 569b8898ef | |||
| 091d6a1cf1 | |||
| 228564f843 | |||
| 17dd6141fb | |||
| 2f57d55bea | |||
| 0cd690d980 | |||
| 316a2aee1a | |||
| 77ce56ba84 | |||
| eba824a5b1 | |||
| c9e72f8fb9 | |||
| 4dd3fd9674 | |||
| 1690a55f7d | |||
| dd29fc26e3 | |||
| 67b5921d6c | |||
| 1b7bcc5cc1 | |||
| ea56bb772a | |||
| 9f33791922 | |||
| d940590add | |||
| a009b4cb6d | |||
| 781be527ce | |||
| f7e5d8f62d | |||
| d6c0fc734f | |||
| db79b5ff53 | |||
| 18d439dd44 | |||
| d2d2a6c086 | |||
| 5260689b8e | |||
| 78a2863b78 | |||
| 5481375347 | |||
| 4da2d2f516 | |||
| 23de8e881d | |||
| 59e8720866 | |||
| 67cce5f817 | |||
| 9924727729 | |||
| 6c9fc364a3 | |||
| 40598d2663 | |||
| 1aeb674733 | |||
| 832cac106e | |||
| 23daaa2387 | |||
| d6016f9b81 | |||
| 1dfde76b95 | |||
| 83f8fac088 | |||
| 0ca9bff61b | |||
| df91192b97 | |||
| 97fd550402 | |||
| eb579de199 | |||
| 5f2984aa57 | |||
| 98a79f6475 | |||
| c09da2b7eb | |||
| 600f74576d | |||
| a21c49cba7 | |||
| 91e3dd022a | |||
| 478eca47f4 | |||
| 1c4358086d | |||
| 0917fc8be1 | |||
| 6833959f3b | |||
| 398376108d | |||
| 61aa029582 | |||
| 46eaa63625 | |||
| 1b54596c5e | |||
| 2feb778d8d | |||
| 2075e89239 | |||
| be44431fde | |||
| 99cc4078c6 | |||
| b8b6b38a20 | |||
| efd8556129 |
+1
-1
@@ -2,7 +2,7 @@ image:
|
||||
file: .gitpod.dockerfile
|
||||
|
||||
tasks:
|
||||
- before: nvm install 16.13.2 && nvm use 16.13.2
|
||||
- before: nvm install 16.14.2 && nvm use 16.14.2
|
||||
init: npm install
|
||||
command: npm run start-server
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Please treat each other with respect and understanding.
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
FROM node:16.13.2-alpine
|
||||
# !!! Don't try to build this Dockerfile directly, run it through bin/build-docker.sh script !!!
|
||||
FROM node:16.14.2-alpine
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
@@ -7,6 +7,10 @@ Trilium Notes是一个分层的笔记应用程序,专注于建立大型个人
|
||||
|
||||

|
||||
|
||||
Ukraine is currently suffering from Russian aggression, please consider donating to [one of these charities](https://old.reddit.com/r/ukraine/comments/s6g5un/want_to_support_ukraine_heres_a_list_of_charities/).
|
||||
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/4/49/Flag_of_Ukraine.svg" alt="drawing" width="600"/>
|
||||
|
||||
## 特性
|
||||
|
||||
* 笔记可以排列成任意深的树。单个笔记可以放在树中的多个位置(请参阅[克隆](https://github.com/zadam/trilium/wiki/Cloning-notes))
|
||||
|
||||
@@ -7,6 +7,10 @@ Trilium Notes is a hierarchical note taking application with focus on building l
|
||||
|
||||

|
||||
|
||||
Ukraine is currently suffering from Russian aggression, please consider donating to [one of these charities](https://old.reddit.com/r/ukraine/comments/s6g5un/want_to_support_ukraine_heres_a_list_of_charities/).
|
||||
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/4/49/Flag_of_Ukraine.svg" alt="drawing" width="600"/>
|
||||
|
||||
## Features
|
||||
|
||||
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://github.com/zadam/trilium/wiki/Cloning-notes))
|
||||
@@ -16,9 +20,12 @@ Trilium Notes is a hierarchical note taking application with focus on building l
|
||||
* Seamless [note versioning](https://github.com/zadam/trilium/wiki/Note-revisions)
|
||||
* Note [attributes](https://github.com/zadam/trilium/wiki/Attributes) can be used for note organization, querying and advanced [scripting](https://github.com/zadam/trilium/wiki/Scripts)
|
||||
* [Synchronization](https://github.com/zadam/trilium/wiki/Synchronization) with self-hosted sync server
|
||||
* there's a [3rd party service for hosting synchronisation server](https://trilium.cc/paid-hosting)
|
||||
* [Sharing](https://github.com/zadam/trilium/wiki/Sharing) (publishing) notes to public internet
|
||||
* Strong [note encryption](https://github.com/zadam/trilium/wiki/Protected-notes) with per-note granularity
|
||||
* [Relation maps](https://github.com/zadam/trilium/wiki/Relation-map) and [link maps](https://github.com/zadam/trilium/wiki/Link-map) for visualizing notes and their relations
|
||||
* [Scripting](https://github.com/zadam/trilium/wiki/Scripts) - see [Advanced showcases](https://github.com/zadam/trilium/wiki/Advanced-showcases)
|
||||
* [REST API](https://github.com/zadam/trilium/wiki/ETAPI) for automation
|
||||
* Scales well in both usability and performance upwards of 100 000 notes
|
||||
* Touch optimized [mobile frontend](https://github.com/zadam/trilium/wiki/Mobile-frontend) for smartphones and tablets
|
||||
* [Night theme](https://github.com/zadam/trilium/wiki/Themes)
|
||||
@@ -58,6 +65,10 @@ npm run start-server
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library without competition. Used in [relation maps](https://github.com/zadam/trilium/wiki/Relation-map) and [link maps](https://github.com/zadam/trilium/wiki/Link-map)
|
||||
|
||||
## Donating
|
||||
|
||||
You can donate using GitHub Sponsors, [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2).
|
||||
|
||||
## License
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
@@ -7,6 +7,10 @@ Trilium Notes – это приложение для заметок с иера
|
||||
|
||||

|
||||
|
||||
Ukraine is currently suffering from Russian aggression, please consider donating to [one of these charities](https://old.reddit.com/r/ukraine/comments/s6g5un/want_to_support_ukraine_heres_a_list_of_charities/).
|
||||
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/4/49/Flag_of_Ukraine.svg" alt="drawing" width="600"/>
|
||||
|
||||
## Возможности
|
||||
|
||||
* Заметки можно расположить в виде дерева произвольной глубины. Отдельную заметку можно разместить в нескольких местах дерева (см. [клонирование](https://github.com/zadam/trilium/wiki/Cloning-notes))
|
||||
|
||||
@@ -29,6 +29,8 @@ rm -r $BUILD_DIR/swiftshader
|
||||
|
||||
cp bin/tpl/anonymize-database.sql $BUILD_DIR/
|
||||
|
||||
cp -r dump-db $BUILD_DIR/
|
||||
|
||||
cp bin/tpl/trilium-portable.sh $BUILD_DIR/
|
||||
chmod 755 $BUILD_DIR/trilium-portable.sh
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ mv "./dist/Trilium Notes-darwin-x64" $BUILD_DIR
|
||||
|
||||
cp bin/tpl/anonymize-database.sql $BUILD_DIR/
|
||||
|
||||
cp -r dump-db $BUILD_DIR/
|
||||
|
||||
echo "Zipping mac x64 electron distribution..."
|
||||
|
||||
VERSION=`jq -r ".version" package.json`
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
PKG_DIR=dist/trilium-linux-x64-server
|
||||
NODE_VERSION=16.13.2
|
||||
NODE_VERSION=16.14.2
|
||||
|
||||
if [ "$1" != "DONTCOPY" ]
|
||||
then
|
||||
@@ -30,6 +30,8 @@ chmod 755 $PKG_DIR/trilium.sh
|
||||
|
||||
cp bin/tpl/anonymize-database.sql $PKG_DIR/
|
||||
|
||||
cp -r dump-db $PKG_DIR/
|
||||
|
||||
VERSION=`jq -r ".version" package.json`
|
||||
|
||||
cd dist
|
||||
|
||||
@@ -27,6 +27,8 @@ rm -r $BUILD_DIR/swiftshader
|
||||
|
||||
cp bin/tpl/anonymize-database.sql $BUILD_DIR/
|
||||
|
||||
cp -r dump-db $BUILD_DIR/
|
||||
|
||||
cp bin/tpl/trilium-{portable,no-cert-check,safe-mode}.{bat,ps1} $BUILD_DIR/
|
||||
|
||||
echo "Zipping windows x64 electron distribution..."
|
||||
|
||||
+8
-2
@@ -5,7 +5,7 @@ if [[ $# -eq 0 ]] ; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
n exec 16.13.2 npm run webpack
|
||||
n exec 16.14.2 npm run webpack
|
||||
|
||||
DIR=$1
|
||||
|
||||
@@ -14,6 +14,9 @@ mkdir $DIR
|
||||
|
||||
echo "Copying Trilium to build directory $DIR"
|
||||
|
||||
cp -r dump-db $DIR/
|
||||
rm -rf $DIR/dump-db/node_modules
|
||||
|
||||
cp -r images $DIR/
|
||||
cp -r libraries $DIR/
|
||||
cp -r src $DIR/
|
||||
@@ -27,7 +30,7 @@ cp -r electron.js $DIR/
|
||||
cp webpack-* $DIR/
|
||||
|
||||
# run in subshell (so we return to original dir)
|
||||
(cd $DIR && n exec 16.13.2 npm install --only=prod)
|
||||
(cd $DIR && n exec 16.14.2 npm install --only=prod)
|
||||
|
||||
# cleanup of useless files in dependencies
|
||||
rm -r $DIR/node_modules/image-q/demo
|
||||
@@ -44,8 +47,11 @@ find $DIR/node_modules -name demo -exec rm -rf {} \;
|
||||
|
||||
find $DIR/libraries -name "*.map" -type f -delete
|
||||
|
||||
cp $DIR/src/public/app/share.js $DIR/src/public/app-dist/
|
||||
|
||||
rm -rf $DIR/src/public/app
|
||||
|
||||
sed -i -e 's/app\/desktop.js/app-dist\/desktop.js/g' $DIR/src/views/desktop.ejs
|
||||
sed -i -e 's/app\/mobile.js/app-dist\/mobile.js/g' $DIR/src/views/mobile.ejs
|
||||
sed -i -e 's/app\/setup.js/app-dist\/setup.js/g' $DIR/src/views/setup.ejs
|
||||
sed -i -e 's/app\/share.js/app-dist\/share.js/g' $DIR/src/views/share/*.ejs
|
||||
|
||||
@@ -4,4 +4,4 @@ const anonymizationService = require('../src/services/anonymization');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
fs.writeFileSync(path.resolve(__dirname, 'tpl', 'anonymize-database.sql'), anonymizationService.getAnonymizationScript());
|
||||
fs.writeFileSync(path.resolve(__dirname, 'tpl', 'anonymize-database.sql'), anonymizationService.getFullAnonymizationScript());
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
|
||||
UPDATE etapi_tokens SET tokenHash = 'API token hash value';
|
||||
UPDATE notes SET title = 'title';
|
||||
UPDATE notes SET title = 'title' WHERE title NOT IN ('root', 'hidden', 'share');
|
||||
UPDATE note_contents SET content = 'text' WHERE content IS NOT NULL;
|
||||
UPDATE note_revisions SET title = 'title';
|
||||
UPDATE note_revision_contents SET content = 'text' WHERE content IS NOT NULL;
|
||||
|
||||
UPDATE attributes SET name = 'name', value = 'value' WHERE type = 'label' AND name NOT IN('inbox', 'disableVersioning', 'calendarRoot', 'archived', 'excludeFromExport', 'disableInclusion', 'appCss', 'appTheme', 'hidePromotedAttributes', 'readOnly', 'autoReadOnlyDisabled', 'hoistedCssClass', 'cssClass', 'iconClass', 'keyboardShortcut', 'run', 'runOnInstance', 'runAtHour', 'customRequestHandler', 'customResourceProvider', 'widget', 'noteInfoWidgetDisabled', 'linkMapWidgetDisabled', 'noteRevisionsWidgetDisabled', 'whatLinksHereWidgetDisabled', 'similarNotesWidgetDisabled', 'workspace', 'workspaceIconClass', 'workspaceTabBackgroundColor', 'searchHome', 'hoistedInbox', 'hoistedSearchHome', 'sqlConsoleHome', 'datePattern', 'pageSize', 'viewType', 'mapRootNoteId', 'bookmarked', 'bookmarkFolder', 'sorted', 'top', 'fullContentWidth', 'shareHiddenFromTree', 'shareAlias', 'shareOmitDefaultCss', 'internalLink', 'imageLink', 'relationMapLink', 'includeMapLink', 'runOnNoteCreation', 'runOnNoteTitleChange', 'runOnNoteChange', 'runOnChildNoteCreation', 'runOnAttributeCreation', 'runOnAttributeChange', 'template', 'widget', 'renderNote', 'shareCss', 'shareJs', 'shareFavicon');
|
||||
UPDATE attributes SET name = 'name' WHERE type = 'relation' AND name NOT IN ('inbox', 'disableVersioning', 'calendarRoot', 'archived', 'excludeFromExport', 'disableInclusion', 'appCss', 'appTheme', 'hidePromotedAttributes', 'readOnly', 'autoReadOnlyDisabled', 'hoistedCssClass', 'cssClass', 'iconClass', 'keyboardShortcut', 'run', 'runOnInstance', 'runAtHour', 'customRequestHandler', 'customResourceProvider', 'widget', 'noteInfoWidgetDisabled', 'linkMapWidgetDisabled', 'noteRevisionsWidgetDisabled', 'whatLinksHereWidgetDisabled', 'similarNotesWidgetDisabled', 'workspace', 'workspaceIconClass', 'workspaceTabBackgroundColor', 'searchHome', 'hoistedInbox', 'hoistedSearchHome', 'sqlConsoleHome', 'datePattern', 'pageSize', 'viewType', 'mapRootNoteId', 'bookmarked', 'bookmarkFolder', 'sorted', 'top', 'fullContentWidth', 'shareHiddenFromTree', 'shareAlias', 'shareOmitDefaultCss', 'internalLink', 'imageLink', 'relationMapLink', 'includeMapLink', 'runOnNoteCreation', 'runOnNoteTitleChange', 'runOnNoteChange', 'runOnChildNoteCreation', 'runOnAttributeCreation', 'runOnAttributeChange', 'template', 'widget', 'renderNote', 'shareCss', 'shareJs', 'shareFavicon');
|
||||
UPDATE branches SET prefix = 'prefix' WHERE prefix IS NOT NULL;
|
||||
UPDATE attributes SET name = 'name', value = 'value' WHERE type = 'label' AND name NOT IN('inbox', 'disableVersioning', 'calendarRoot', 'archived', 'excludeFromExport', 'disableInclusion', 'appCss', 'appTheme', 'hidePromotedAttributes', 'readOnly', 'autoReadOnlyDisabled', 'hoistedCssClass', 'cssClass', 'iconClass', 'keyboardShortcut', 'run', 'runOnInstance', 'runAtHour', 'customRequestHandler', 'customResourceProvider', 'widget', 'noteInfoWidgetDisabled', 'linkMapWidgetDisabled', 'noteRevisionsWidgetDisabled', 'whatLinksHereWidgetDisabled', 'similarNotesWidgetDisabled', 'workspace', 'workspaceIconClass', 'workspaceTabBackgroundColor', 'searchHome', 'hoistedInbox', 'hoistedSearchHome', 'sqlConsoleHome', 'datePattern', 'pageSize', 'viewType', 'mapRootNoteId', 'bookmarked', 'bookmarkFolder', 'sorted', 'top', 'fullContentWidth', 'shareHiddenFromTree', 'shareAlias', 'shareOmitDefaultCss', 'shareRoot', 'internalLink', 'imageLink', 'relationMapLink', 'includeMapLink', 'runOnNoteCreation', 'runOnNoteTitleChange', 'runOnNoteChange', 'runOnChildNoteCreation', 'runOnAttributeCreation', 'runOnAttributeChange', 'template', 'widget', 'renderNote', 'shareCss', 'shareJs', 'shareFavicon');
|
||||
UPDATE attributes SET name = 'name' WHERE type = 'relation' AND name NOT IN ('inbox', 'disableVersioning', 'calendarRoot', 'archived', 'excludeFromExport', 'disableInclusion', 'appCss', 'appTheme', 'hidePromotedAttributes', 'readOnly', 'autoReadOnlyDisabled', 'hoistedCssClass', 'cssClass', 'iconClass', 'keyboardShortcut', 'run', 'runOnInstance', 'runAtHour', 'customRequestHandler', 'customResourceProvider', 'widget', 'noteInfoWidgetDisabled', 'linkMapWidgetDisabled', 'noteRevisionsWidgetDisabled', 'whatLinksHereWidgetDisabled', 'similarNotesWidgetDisabled', 'workspace', 'workspaceIconClass', 'workspaceTabBackgroundColor', 'searchHome', 'hoistedInbox', 'hoistedSearchHome', 'sqlConsoleHome', 'datePattern', 'pageSize', 'viewType', 'mapRootNoteId', 'bookmarked', 'bookmarkFolder', 'sorted', 'top', 'fullContentWidth', 'shareHiddenFromTree', 'shareAlias', 'shareOmitDefaultCss', 'shareRoot', 'internalLink', 'imageLink', 'relationMapLink', 'includeMapLink', 'runOnNoteCreation', 'runOnNoteTitleChange', 'runOnNoteChange', 'runOnChildNoteCreation', 'runOnAttributeCreation', 'runOnAttributeChange', 'template', 'widget', 'renderNote', 'shareCss', 'shareJs', 'shareFavicon');
|
||||
UPDATE branches SET prefix = 'prefix' WHERE prefix IS NOT NULL AND prefix != 'recovered';
|
||||
UPDATE options SET value = 'anonymized' WHERE name IN
|
||||
('documentId', 'documentSecret', 'encryptedDataKey',
|
||||
'passwordVerificationHash', 'passwordVerificationSalt',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Trilium Notes DB dump tool
|
||||
|
||||
This is a simple tool to dump the content of Trilium's document.db onto filesystem.
|
||||
|
||||
It is meant as a last resort solution when the standard mean to access your data (through main Trilium application) fail.
|
||||
|
||||
## Installation
|
||||
|
||||
This tool requires node.js, testing has been done on 16.14.2, but it will probably work on other versions as well.
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
See output of `node dump-db.js --help`:
|
||||
|
||||
```
|
||||
dump-db.js <path_to_document> <target_directory>
|
||||
|
||||
dump the contents of document.db into the target directory
|
||||
|
||||
Positionals:
|
||||
path_to_document path to the document.db
|
||||
target_directory path of the directory into which the notes should be dumped
|
||||
|
||||
Options:
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
--password Set password to be able to decrypt protected notes.[string]
|
||||
--include-deleted If set to true, dump also deleted notes.
|
||||
[boolean] [default: false]
|
||||
```
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const yargs = require('yargs/yargs')
|
||||
const { hideBin } = require('yargs/helpers')
|
||||
const dumpService = require("./inc/dump.js");
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.command('$0 <path_to_document> <target_directory>', 'dump the contents of document.db into the target directory', (yargs) => {
|
||||
return yargs
|
||||
.positional('path_to_document', { describe: 'path to the document.db' })
|
||||
.positional('target_directory', { describe: 'path of the directory into which the notes should be dumped' })
|
||||
}, (argv) => {
|
||||
try {
|
||||
dumpService.dumpDocument(argv.path_to_document, argv.target_directory, {
|
||||
includeDeleted: argv.includeDeleted,
|
||||
password: argv.password
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Unrecoverable error:`, e);
|
||||
process.exit(1);
|
||||
}
|
||||
})
|
||||
.option('password', {
|
||||
type: 'string',
|
||||
description: 'Set password to be able to decrypt protected notes.'
|
||||
})
|
||||
.option('include-deleted', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'If set to true, dump also deleted notes.'
|
||||
})
|
||||
.parse();
|
||||
@@ -0,0 +1,43 @@
|
||||
const crypto = require("crypto");
|
||||
const sql = require("./sql.js");
|
||||
const decryptService = require("./decrypt.js");
|
||||
|
||||
function getDataKey(password) {
|
||||
if (!password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordDerivedKey = getPasswordDerivedKey(password);
|
||||
|
||||
const encryptedDataKey = getOption('encryptedDataKey');
|
||||
|
||||
const decryptedDataKey = decryptService.decrypt(passwordDerivedKey, encryptedDataKey, 16);
|
||||
|
||||
return decryptedDataKey;
|
||||
}
|
||||
catch (e) {
|
||||
throw new Error(`Cannot read data key, the entered password might be wrong. The underlying error: '${e.message}', stack:\n${e.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getPasswordDerivedKey(password) {
|
||||
const salt = getOption('passwordDerivedKeySalt');
|
||||
|
||||
return getScryptHash(password, salt);
|
||||
}
|
||||
|
||||
function getScryptHash(password, salt) {
|
||||
const hashed = crypto.scryptSync(password, salt, 32,
|
||||
{N: 16384, r:8, p:1});
|
||||
|
||||
return hashed;
|
||||
}
|
||||
|
||||
function getOption(name) {
|
||||
return sql.getValue("SELECT value FROM options WHERE name = ?", [name]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDataKey
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
const crypto = require("crypto");
|
||||
|
||||
function decryptString(dataKey, cipherText) {
|
||||
const buffer = decrypt(dataKey, cipherText);
|
||||
|
||||
if (buffer === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const str = buffer.toString('utf-8');
|
||||
|
||||
if (str === 'false') {
|
||||
throw new Error("Could not decrypt string.");
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
function decrypt(key, cipherText, ivLength = 13) {
|
||||
if (cipherText === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
return "[protected]";
|
||||
}
|
||||
|
||||
try {
|
||||
const cipherTextBufferWithIv = Buffer.from(cipherText.toString(), 'base64');
|
||||
const iv = cipherTextBufferWithIv.slice(0, ivLength);
|
||||
|
||||
const cipherTextBuffer = cipherTextBufferWithIv.slice(ivLength);
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', pad(key), pad(iv));
|
||||
|
||||
const decryptedBytes = Buffer.concat([decipher.update(cipherTextBuffer), decipher.final()]);
|
||||
|
||||
const digest = decryptedBytes.slice(0, 4);
|
||||
const payload = decryptedBytes.slice(4);
|
||||
|
||||
const computedDigest = shaArray(payload).slice(0, 4);
|
||||
|
||||
if (!arraysIdentical(digest, computedDigest)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
catch (e) {
|
||||
// recovery from https://github.com/zadam/trilium/issues/510
|
||||
if (e.message && e.message.includes("WRONG_FINAL_BLOCK_LENGTH")) {
|
||||
log.info("Caught WRONG_FINAL_BLOCK_LENGTH, returning cipherText instead");
|
||||
|
||||
return cipherText;
|
||||
}
|
||||
else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pad(data) {
|
||||
if (data.length > 16) {
|
||||
data = data.slice(0, 16);
|
||||
}
|
||||
else if (data.length < 16) {
|
||||
const zeros = Array(16 - data.length).fill(0);
|
||||
|
||||
data = Buffer.concat([data, Buffer.from(zeros)]);
|
||||
}
|
||||
|
||||
return Buffer.from(data);
|
||||
}
|
||||
|
||||
function arraysIdentical(a, b) {
|
||||
let i = a.length;
|
||||
if (i !== b.length) return false;
|
||||
while (i--) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shaArray(content) {
|
||||
// we use this as simple checksum and don't rely on its security so SHA-1 is good enough
|
||||
return crypto.createHash('sha1').update(content).digest();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decrypt,
|
||||
decryptString
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
const fs = require("fs");
|
||||
const sanitize = require("sanitize-filename");
|
||||
const sql = require("./sql.js");
|
||||
const decryptService = require("./decrypt.js");
|
||||
const dataKeyService = require("./data_key.js");
|
||||
const extensionService = require("./extension.js");
|
||||
|
||||
function dumpDocument(documentPath, targetPath, options) {
|
||||
const stats = {
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
protected: 0,
|
||||
deleted: 0
|
||||
};
|
||||
|
||||
validatePaths(documentPath, targetPath);
|
||||
|
||||
sql.openDatabase(documentPath);
|
||||
|
||||
const dataKey = dataKeyService.getDataKey(options.password);
|
||||
|
||||
const existingPaths = {};
|
||||
const noteIdToPath = {};
|
||||
|
||||
dumpNote(targetPath, 'root');
|
||||
|
||||
printDumpResults(stats, options);
|
||||
|
||||
function dumpNote(targetPath, noteId) {
|
||||
console.log(`Reading note '${noteId}'`);
|
||||
|
||||
let childTargetPath, note, fileNameWithPath;
|
||||
|
||||
try {
|
||||
note = sql.getRow("SELECT * FROM notes WHERE noteId = ?", [noteId]);
|
||||
|
||||
if (note.isDeleted) {
|
||||
stats.deleted++;
|
||||
|
||||
if (!options.includeDeleted) {
|
||||
console.log(`Note '${noteId}' is deleted and --include-deleted option is not used, skipping.`);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (note.isProtected) {
|
||||
stats.protected++;
|
||||
|
||||
note.title = decryptService.decryptString(dataKey, note.title);
|
||||
}
|
||||
|
||||
let safeTitle = sanitize(note.title);
|
||||
|
||||
if (safeTitle.length > 20) {
|
||||
safeTitle = safeTitle.substring(0, 20);
|
||||
}
|
||||
|
||||
childTargetPath = targetPath + '/' + safeTitle;
|
||||
|
||||
for (let i = 1; i < 100000 && childTargetPath in existingPaths; i++) {
|
||||
childTargetPath = targetPath + '/' + safeTitle + '_' + i;
|
||||
}
|
||||
|
||||
existingPaths[childTargetPath] = true;
|
||||
|
||||
if (note.noteId in noteIdToPath) {
|
||||
const message = `Note '${noteId}' has been already dumped to ${noteIdToPath[note.noteId]}`;
|
||||
|
||||
console.log(message);
|
||||
|
||||
fs.writeFileSync(childTargetPath, message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let {content} = sql.getRow("SELECT content FROM note_contents WHERE noteId = ?", [noteId]);
|
||||
|
||||
if (content !== null && note.isProtected && dataKey) {
|
||||
content = decryptService.decrypt(dataKey, content);
|
||||
}
|
||||
|
||||
if (isContentEmpty(content)) {
|
||||
console.log(`Note '${noteId}' is empty, skipping.`);
|
||||
} else {
|
||||
fileNameWithPath = extensionService.getFileName(note, childTargetPath, safeTitle);
|
||||
|
||||
fs.writeFileSync(fileNameWithPath, content);
|
||||
|
||||
stats.succeeded++;
|
||||
|
||||
console.log(`Dumped note '${noteId}' into ${fileNameWithPath} successfully.`);
|
||||
}
|
||||
|
||||
noteIdToPath[noteId] = childTargetPath;
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`DUMPERROR: Writing '${noteId}' failed with error '${e.message}':\n${e.stack}`);
|
||||
|
||||
stats.failed++;
|
||||
}
|
||||
|
||||
const childNoteIds = sql.getColumn("SELECT noteId FROM branches WHERE parentNoteId = ?", [noteId]);
|
||||
|
||||
if (childNoteIds.length > 0) {
|
||||
if (childTargetPath === fileNameWithPath) {
|
||||
childTargetPath += '_dir';
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(childTargetPath, {recursive: true});
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`DUMPERROR: Creating directory ${childTargetPath} failed with error '${e.message}'`);
|
||||
}
|
||||
|
||||
for (const childNoteId of childNoteIds) {
|
||||
dumpNote(childTargetPath, childNoteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printDumpResults(stats, options) {
|
||||
console.log('\n----------------------- STATS -----------------------');
|
||||
console.log('Successfully dumpted notes: ', stats.succeeded.toString().padStart(5, ' '));
|
||||
console.log('Protected notes: ', stats.protected.toString().padStart(5, ' '), options.password ? '' : '(skipped)');
|
||||
console.log('Failed notes: ', stats.failed.toString().padStart(5, ' '));
|
||||
console.log('Deleted notes: ', stats.deleted.toString().padStart(5, ' '), options.includeDeleted ? "(dumped)" : "(at least, skipped)");
|
||||
console.log('-----------------------------------------------------');
|
||||
|
||||
if (!options.password && stats.protected > 0) {
|
||||
console.log("\nWARNING: protected notes are present in the document but no password has been provided. Protected notes have not been dumped.");
|
||||
}
|
||||
}
|
||||
|
||||
function isContentEmpty(content) {
|
||||
if (!content) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof content === "string") {
|
||||
return !content.trim() || content.trim() === '<p></p>';
|
||||
}
|
||||
else if (Buffer.isBuffer(content)) {
|
||||
return content.length === 0;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validatePaths(documentPath, targetPath) {
|
||||
if (!fs.existsSync(documentPath)) {
|
||||
console.error(`Path to document '${documentPath}' has not been found. Run with --help to see usage.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
const ret = fs.mkdirSync(targetPath, {recursive: true});
|
||||
|
||||
if (!ret) {
|
||||
console.error(`Target path '${targetPath}' could not be created. Run with --help to see usage.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dumpDocument
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
const path = require("path");
|
||||
const mimeTypes = require("mime-types");
|
||||
|
||||
function getFileName(note, childTargetPath, safeTitle) {
|
||||
let existingExtension = path.extname(safeTitle).toLowerCase();
|
||||
let newExtension;
|
||||
|
||||
if (note.type === 'text') {
|
||||
newExtension = 'html';
|
||||
} else if (note.mime === 'application/x-javascript' || note.mime === 'text/javascript') {
|
||||
newExtension = 'js';
|
||||
} else if (existingExtension.length > 0) { // if the page already has an extension, then we'll just keep it
|
||||
newExtension = null;
|
||||
} else {
|
||||
if (note.mime?.toLowerCase()?.trim() === "image/jpg") { // image/jpg is invalid but pretty common
|
||||
newExtension = 'jpg';
|
||||
} else {
|
||||
newExtension = mimeTypes.extension(note.mime) || "dat";
|
||||
}
|
||||
}
|
||||
|
||||
let fileNameWithPath = childTargetPath;
|
||||
|
||||
// if the note is already named with extension (e.g. "jquery"), then it's silly to append exact same extension again
|
||||
if (newExtension && existingExtension !== "." + newExtension.toLowerCase()) {
|
||||
fileNameWithPath += "." + newExtension;
|
||||
}
|
||||
|
||||
return fileNameWithPath;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getFileName
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
const Database = require("better-sqlite3");
|
||||
let dbConnection;
|
||||
|
||||
const openDatabase = (documentPath) => { dbConnection = new Database(documentPath, { readonly: true }) };
|
||||
|
||||
const getRow = (query, params = []) => dbConnection.prepare(query).get(params);
|
||||
const getRows = (query, params = []) => dbConnection.prepare(query).all(params);
|
||||
const getValue = (query, params = []) => dbConnection.prepare(query).pluck().get(params);
|
||||
const getColumn = (query, params = []) => dbConnection.prepare(query).pluck().all(params);
|
||||
|
||||
module.exports = {
|
||||
openDatabase,
|
||||
getRow,
|
||||
getRows,
|
||||
getValue,
|
||||
getColumn
|
||||
};
|
||||
Generated
+1553
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "dump-db",
|
||||
"version": "1.0.0",
|
||||
"description": "Standalone tool to dump contents of Trilium document.db file into a directory tree of notes",
|
||||
"main": "dump-db.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zadam/trilium.git"
|
||||
},
|
||||
"author": "zadam",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/zadam/trilium/issues"
|
||||
},
|
||||
"homepage": "https://github.com/zadam/trilium/dump-db#readme",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "7.5.0",
|
||||
"mime-types": "2.1.34",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"yargs": "17.3.1"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* CKEditor 5 (v33.0.0) content styles.
|
||||
* Generated on Fri, 11 Mar 2022 14:34:26 GMT.
|
||||
* For more information, check out https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/content-styles.html
|
||||
*/
|
||||
|
||||
:root {
|
||||
--ck-color-image-caption-background: hsl(0, 0%, 97%);
|
||||
--ck-color-image-caption-text: hsl(0, 0%, 20%);
|
||||
@@ -22,6 +28,43 @@
|
||||
--ck-todo-list-checkmark-size: 16px;
|
||||
}
|
||||
|
||||
/* ckeditor5-basic-styles/theme/code.css */
|
||||
.ck-content code {
|
||||
background-color: hsla(0, 0%, 78%, 0.3);
|
||||
padding: .15em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* ckeditor5-font/theme/fontsize.css */
|
||||
.ck-content .text-tiny {
|
||||
font-size: .7em;
|
||||
}
|
||||
/* ckeditor5-font/theme/fontsize.css */
|
||||
.ck-content .text-small {
|
||||
font-size: .85em;
|
||||
}
|
||||
/* ckeditor5-font/theme/fontsize.css */
|
||||
.ck-content .text-big {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
/* ckeditor5-font/theme/fontsize.css */
|
||||
.ck-content .text-huge {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
/* ckeditor5-block-quote/theme/blockquote.css */
|
||||
.ck-content blockquote {
|
||||
overflow: hidden;
|
||||
padding-right: 1.5em;
|
||||
padding-left: 1.5em;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
font-style: italic;
|
||||
border-left: solid 5px hsl(0, 0%, 80%);
|
||||
}
|
||||
/* ckeditor5-block-quote/theme/blockquote.css */
|
||||
.ck-content[dir="rtl"] blockquote {
|
||||
border-left: 0;
|
||||
border-right: solid 5px hsl(0, 0%, 80%);
|
||||
}
|
||||
/* ckeditor5-highlight/theme/highlight.css */
|
||||
.ck-content .marker-yellow {
|
||||
background-color: var(--ck-highlight-marker-yellow);
|
||||
@@ -48,21 +91,53 @@
|
||||
color: var(--ck-highlight-pen-green);
|
||||
background-color: transparent;
|
||||
}
|
||||
/* ckeditor5-font/theme/fontsize.css */
|
||||
.ck-content .text-tiny {
|
||||
font-size: .7em;
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image {
|
||||
display: table;
|
||||
clear: both;
|
||||
text-align: center;
|
||||
margin: 0.9em auto;
|
||||
min-width: 50px;
|
||||
}
|
||||
/* ckeditor5-font/theme/fontsize.css */
|
||||
.ck-content .text-small {
|
||||
font-size: .85em;
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
/* ckeditor5-font/theme/fontsize.css */
|
||||
.ck-content .text-big {
|
||||
font-size: 1.4em;
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image-inline {
|
||||
/*
|
||||
* Normally, the .image-inline would have "display: inline-block" and "img { width: 100% }" (to follow the wrapper while resizing).;
|
||||
* Unfortunately, together with "srcset", it gets automatically stretched up to the width of the editing root.
|
||||
* This strange behavior does not happen with inline-flex.
|
||||
*/
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
/* ckeditor5-font/theme/fontsize.css */
|
||||
.ck-content .text-huge {
|
||||
font-size: 1.8em;
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image-inline picture {
|
||||
display: flex;
|
||||
}
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image-inline picture,
|
||||
.ck-content .image-inline img {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* ckeditor5-image/theme/imagecaption.css */
|
||||
.ck-content .image > figcaption {
|
||||
display: table-caption;
|
||||
caption-side: bottom;
|
||||
word-break: break-word;
|
||||
color: var(--ck-color-image-caption-text);
|
||||
background-color: var(--ck-color-image-caption-background);
|
||||
padding: .6em;
|
||||
font-size: .75em;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
/* ckeditor5-image/theme/imagestyle.css */
|
||||
.ck-content .image-style-block-align-left,
|
||||
@@ -125,54 +200,6 @@
|
||||
.ck-content .image-inline.image-style-align-right {
|
||||
margin-left: var(--ck-inline-image-style-spacing);
|
||||
}
|
||||
/* ckeditor5-image/theme/imagecaption.css */
|
||||
.ck-content .image > figcaption {
|
||||
display: table-caption;
|
||||
caption-side: bottom;
|
||||
word-break: break-word;
|
||||
color: var(--ck-color-image-caption-text);
|
||||
background-color: var(--ck-color-image-caption-background);
|
||||
padding: .6em;
|
||||
font-size: .75em;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image {
|
||||
display: table;
|
||||
clear: both;
|
||||
text-align: center;
|
||||
margin: 0.9em auto;
|
||||
min-width: 50px;
|
||||
}
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image-inline {
|
||||
/*
|
||||
* Normally, the .image-inline would have "display: inline-block" and "img { width: 100% }" (to follow the wrapper while resizing).;
|
||||
* Unfortunately, together with "srcset", it gets automatically stretched up to the width of the editing root.
|
||||
* This strange behavior does not happen with inline-flex.
|
||||
*/
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image-inline picture {
|
||||
display: flex;
|
||||
}
|
||||
/* ckeditor5-image/theme/image.css */
|
||||
.ck-content .image-inline picture,
|
||||
.ck-content .image-inline img {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* ckeditor5-image/theme/imageresize.css */
|
||||
.ck-content .image.image_resized {
|
||||
max-width: 100%;
|
||||
@@ -187,141 +214,9 @@
|
||||
.ck-content .image.image_resized > figcaption {
|
||||
display: block;
|
||||
}
|
||||
/* ckeditor5-code-block/theme/codeblock.css */
|
||||
.ck-content pre {
|
||||
padding: 1em;
|
||||
color: hsl(0, 0%, 20.8%);
|
||||
background: hsla(0, 0%, 78%, 0.3);
|
||||
border: 1px solid hsl(0, 0%, 77%);
|
||||
border-radius: 2px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
tab-size: 4;
|
||||
white-space: pre-wrap;
|
||||
font-style: normal;
|
||||
min-width: 200px;
|
||||
}
|
||||
/* ckeditor5-code-block/theme/codeblock.css */
|
||||
.ck-content pre code {
|
||||
background: unset;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
/* ckeditor5-horizontal-line/theme/horizontalline.css */
|
||||
.ck-content hr {
|
||||
margin: 15px 0;
|
||||
height: 4px;
|
||||
background: hsl(0, 0%, 87%);
|
||||
border: 0;
|
||||
}
|
||||
/* ckeditor5-block-quote/theme/blockquote.css */
|
||||
.ck-content blockquote {
|
||||
overflow: hidden;
|
||||
padding-right: 1.5em;
|
||||
padding-left: 1.5em;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
/* ckeditor5-language/theme/language.css */
|
||||
.ck-content span[lang] {
|
||||
font-style: italic;
|
||||
border-left: solid 5px hsl(0, 0%, 80%);
|
||||
}
|
||||
/* ckeditor5-block-quote/theme/blockquote.css */
|
||||
.ck-content[dir="rtl"] blockquote {
|
||||
border-left: 0;
|
||||
border-right: solid 5px hsl(0, 0%, 80%);
|
||||
}
|
||||
/* ckeditor5-basic-styles/theme/code.css */
|
||||
.ck-content code {
|
||||
background-color: hsla(0, 0%, 78%, 0.3);
|
||||
padding: .15em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content .table {
|
||||
margin: 0.9em auto;
|
||||
display: table;
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content .table table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px double hsl(0, 0%, 70%);
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content .table table td,
|
||||
.ck-content .table table th {
|
||||
min-width: 2em;
|
||||
padding: .4em;
|
||||
border: 1px solid hsl(0, 0%, 75%);
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content .table table th {
|
||||
font-weight: bold;
|
||||
background: hsla(0, 0%, 0%, 5%);
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content[dir="rtl"] .table th {
|
||||
text-align: right;
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content[dir="ltr"] .table th {
|
||||
text-align: left;
|
||||
}
|
||||
/* ckeditor5-table/theme/tablecaption.css */
|
||||
.ck-content .table > figcaption {
|
||||
display: table-caption;
|
||||
caption-side: top;
|
||||
word-break: break-word;
|
||||
text-align: center;
|
||||
color: var(--ck-color-table-caption-text);
|
||||
background-color: var(--ck-color-table-caption-background);
|
||||
padding: .6em;
|
||||
font-size: .75em;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
/* ckeditor5-page-break/theme/pagebreak.css */
|
||||
.ck-content .page-break {
|
||||
position: relative;
|
||||
clear: both;
|
||||
padding: 5px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* ckeditor5-page-break/theme/pagebreak.css */
|
||||
.ck-content .page-break::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-bottom: 2px dashed hsl(0, 0%, 77%);
|
||||
width: 100%;
|
||||
}
|
||||
/* ckeditor5-page-break/theme/pagebreak.css */
|
||||
.ck-content .page-break__label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: .3em .6em;
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid hsl(0, 0%, 77%);
|
||||
border-radius: 2px;
|
||||
font-family: Helvetica, Arial, Tahoma, Verdana, Sans-Serif;
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
color: hsl(0, 0%, 20%);
|
||||
background: hsl(0, 0%, 100%);
|
||||
box-shadow: 2px 2px 1px hsla(0, 0%, 0%, 0.15);
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
/* ckeditor5-media-embed/theme/mediaembed.css */
|
||||
.ck-content .media {
|
||||
clear: both;
|
||||
margin: 0.9em 0;
|
||||
display: block;
|
||||
min-width: 15em;
|
||||
}
|
||||
/* ckeditor5-list/theme/todolist.css */
|
||||
.ck-content .todo-list {
|
||||
@@ -390,9 +285,120 @@
|
||||
.ck-content .todo-list .todo-list__label .todo-list__label__description {
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* ckeditor5-language/theme/language.css */
|
||||
.ck-content span[lang] {
|
||||
font-style: italic;
|
||||
/* ckeditor5-media-embed/theme/mediaembed.css */
|
||||
.ck-content .media {
|
||||
clear: both;
|
||||
margin: 0.9em 0;
|
||||
display: block;
|
||||
min-width: 15em;
|
||||
}
|
||||
/* ckeditor5-page-break/theme/pagebreak.css */
|
||||
.ck-content .page-break {
|
||||
position: relative;
|
||||
clear: both;
|
||||
padding: 5px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* ckeditor5-page-break/theme/pagebreak.css */
|
||||
.ck-content .page-break::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-bottom: 2px dashed hsl(0, 0%, 77%);
|
||||
width: 100%;
|
||||
}
|
||||
/* ckeditor5-page-break/theme/pagebreak.css */
|
||||
.ck-content .page-break__label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: .3em .6em;
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid hsl(0, 0%, 77%);
|
||||
border-radius: 2px;
|
||||
font-family: Helvetica, Arial, Tahoma, Verdana, Sans-Serif;
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
color: hsl(0, 0%, 20%);
|
||||
background: hsl(0, 0%, 100%);
|
||||
box-shadow: 2px 2px 1px hsla(0, 0%, 0%, 0.15);
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content .table {
|
||||
margin: 0.9em auto;
|
||||
display: table;
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content .table table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px double hsl(0, 0%, 70%);
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content .table table td,
|
||||
.ck-content .table table th {
|
||||
min-width: 2em;
|
||||
padding: .4em;
|
||||
border: 1px solid hsl(0, 0%, 75%);
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content .table table th {
|
||||
font-weight: bold;
|
||||
background: hsla(0, 0%, 0%, 5%);
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content[dir="rtl"] .table th {
|
||||
text-align: right;
|
||||
}
|
||||
/* ckeditor5-table/theme/table.css */
|
||||
.ck-content[dir="ltr"] .table th {
|
||||
text-align: left;
|
||||
}
|
||||
/* ckeditor5-table/theme/tablecaption.css */
|
||||
.ck-content .table > figcaption {
|
||||
display: table-caption;
|
||||
caption-side: top;
|
||||
word-break: break-word;
|
||||
text-align: center;
|
||||
color: var(--ck-color-table-caption-text);
|
||||
background-color: var(--ck-color-table-caption-background);
|
||||
padding: .6em;
|
||||
font-size: .75em;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
/* ckeditor5-code-block/theme/codeblock.css */
|
||||
.ck-content pre {
|
||||
padding: 1em;
|
||||
color: hsl(0, 0%, 20.8%);
|
||||
background: hsla(0, 0%, 78%, 0.3);
|
||||
border: 1px solid hsl(0, 0%, 77%);
|
||||
border-radius: 2px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
tab-size: 4;
|
||||
white-space: pre-wrap;
|
||||
font-style: normal;
|
||||
min-width: 200px;
|
||||
}
|
||||
/* ckeditor5-code-block/theme/codeblock.css */
|
||||
.ck-content pre code {
|
||||
background: unset;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
/* ckeditor5-horizontal-line/theme/horizontalline.css */
|
||||
.ck-content hr {
|
||||
margin: 15px 0;
|
||||
height: 4px;
|
||||
background: hsl(0, 0%, 87%);
|
||||
border: 0;
|
||||
}
|
||||
/* ckeditor5-mention/theme/mention.css */
|
||||
.ck-content .mention {
|
||||
@@ -409,4 +415,3 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+1017
-1222
File diff suppressed because it is too large
Load Diff
+21
-21
@@ -2,7 +2,7 @@
|
||||
"name": "trilium",
|
||||
"productName": "Trilium Notes",
|
||||
"description": "Trilium Notes",
|
||||
"version": "0.50.0-beta",
|
||||
"version": "0.51.0-beta",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "electron.js",
|
||||
"bin": {
|
||||
@@ -26,40 +26,40 @@
|
||||
"dependencies": {
|
||||
"archiver": "5.3.0",
|
||||
"async-mutex": "0.3.2",
|
||||
"axios": "0.24.0",
|
||||
"axios": "0.26.1",
|
||||
"better-sqlite3": "7.4.5",
|
||||
"chokidar": "3.5.2",
|
||||
"chokidar": "3.5.3",
|
||||
"cls-hooked": "4.2.2",
|
||||
"commonmark": "0.30.0",
|
||||
"cookie-parser": "1.4.6",
|
||||
"csurf": "1.11.0",
|
||||
"dayjs": "1.10.7",
|
||||
"dayjs": "1.11.0",
|
||||
"ejs": "3.1.6",
|
||||
"electron-debug": "3.2.0",
|
||||
"electron-dl": "3.3.0",
|
||||
"electron-dl": "3.3.1",
|
||||
"electron-find": "1.0.7",
|
||||
"electron-window-state": "5.0.3",
|
||||
"@electron/remote": "2.0.1",
|
||||
"express": "4.17.2",
|
||||
"@electron/remote": "2.0.8",
|
||||
"express": "4.17.3",
|
||||
"express-partial-content": "1.0.2",
|
||||
"express-rate-limit": "6.1.0",
|
||||
"express-rate-limit": "6.3.0",
|
||||
"express-session": "1.17.2",
|
||||
"fs-extra": "10.0.0",
|
||||
"helmet": "5.0.1",
|
||||
"fs-extra": "10.0.1",
|
||||
"helmet": "5.0.2",
|
||||
"html": "1.0.0",
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "5.0.0",
|
||||
"https-proxy-agent": "5.0.0",
|
||||
"image-type": "4.1.0",
|
||||
"ini": "2.0.0",
|
||||
"ini": "3.0.0",
|
||||
"is-animated": "2.0.2",
|
||||
"is-svg": "4.3.2",
|
||||
"jimp": "0.16.1",
|
||||
"joplin-turndown-plugin-gfm": "1.0.12",
|
||||
"jsdom": "19.0.0",
|
||||
"mime-types": "2.1.34",
|
||||
"mime-types": "2.1.35",
|
||||
"multer": "1.4.4",
|
||||
"node-abi": "3.5.0",
|
||||
"node-abi": "3.8.0",
|
||||
"normalize-strings": "1.1.1",
|
||||
"open": "8.4.0",
|
||||
"portscanner": "2.2.0",
|
||||
@@ -67,9 +67,9 @@
|
||||
"request": "2.88.2",
|
||||
"rimraf": "3.0.2",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-html": "2.6.1",
|
||||
"sanitize-html": "2.7.0",
|
||||
"sax": "1.2.4",
|
||||
"semver": "7.3.5",
|
||||
"semver": "7.3.6",
|
||||
"serve-favicon": "2.5.0",
|
||||
"session-file-store": "1.5.0",
|
||||
"stream-throttle": "0.1.3",
|
||||
@@ -77,22 +77,22 @@
|
||||
"tmp": "0.2.1",
|
||||
"turndown": "7.1.1",
|
||||
"unescape": "1.0.1",
|
||||
"ws": "8.4.2",
|
||||
"ws": "8.5.0",
|
||||
"yauzl": "2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "7.0.3",
|
||||
"electron": "16.0.7",
|
||||
"electron-builder": "22.14.5",
|
||||
"electron": "16.2.1",
|
||||
"electron-builder": "23.0.3",
|
||||
"electron-packager": "15.4.0",
|
||||
"electron-rebuild": "3.2.7",
|
||||
"esm": "3.2.25",
|
||||
"jasmine": "4.0.2",
|
||||
"jsdoc": "3.6.7",
|
||||
"jsdoc": "3.6.10",
|
||||
"lorem-ipsum": "2.0.4",
|
||||
"rcedit": "3.0.1",
|
||||
"webpack": "5.66.0",
|
||||
"webpack-cli": "4.9.1"
|
||||
"webpack": "5.72.0",
|
||||
"webpack-cli": "4.9.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"electron-installer-debian": "3.1.0"
|
||||
|
||||
@@ -25,6 +25,7 @@ app.use(helmet({
|
||||
|
||||
app.use(express.text({limit: '500mb'}));
|
||||
app.use(express.json({limit: '500mb'}));
|
||||
app.use(express.raw({limit: '500mb'}));
|
||||
app.use(express.urlencoded({extended: false}));
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
@@ -6,6 +6,7 @@ const entityChangesService = require('../../services/entity_changes');
|
||||
const eventService = require("../../services/events");
|
||||
const dateUtils = require("../../services/date_utils");
|
||||
const cls = require("../../services/cls");
|
||||
const log = require("../../services/log");
|
||||
|
||||
let becca = null;
|
||||
|
||||
@@ -103,15 +104,21 @@ class AbstractEntity {
|
||||
const entityId = this[this.constructor.primaryKeyName];
|
||||
const entityName = this.constructor.entityName;
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
sql.execute(`UPDATE ${entityName} SET isDeleted = 1, deleteId = ?, utcDateModified = ?
|
||||
WHERE ${this.constructor.primaryKeyName} = ?`,
|
||||
[deleteId, dateUtils.utcNowDateTime(), entityId]);
|
||||
[deleteId, this.utcDateModified, entityId]);
|
||||
|
||||
if (this.dateModified) {
|
||||
this.dateModified = dateUtils.localNowDateTime();
|
||||
|
||||
sql.execute(`UPDATE ${entityName} SET dateModified = ? WHERE ${this.constructor.primaryKeyName} = ?`,
|
||||
[dateUtils.localNowDateTime(), entityId]);
|
||||
[this.dateModified, entityId]);
|
||||
}
|
||||
|
||||
log.info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
|
||||
this.addEntityChange(true);
|
||||
|
||||
eventService.emit(eventService.ENTITY_DELETED, { entityName, entityId, entity: this });
|
||||
@@ -121,9 +128,13 @@ class AbstractEntity {
|
||||
const entityId = this[this.constructor.primaryKeyName];
|
||||
const entityName = this.constructor.entityName;
|
||||
|
||||
this.utcDateModified = dateUtils.utcNowDateTime();
|
||||
|
||||
sql.execute(`UPDATE ${entityName} SET isDeleted = 1, utcDateModified = ?
|
||||
WHERE ${this.constructor.primaryKeyName} = ?`,
|
||||
[dateUtils.utcNowDateTime(), entityId]);
|
||||
[this.utcDateModified, entityId]);
|
||||
|
||||
log.info(`Marking ${entityName} ${entityId} as deleted`);
|
||||
|
||||
this.addEntityChange(true);
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ class EtapiToken extends AbstractEntity {
|
||||
/** @type {boolean} */
|
||||
this.isDeleted = !!row.isDeleted;
|
||||
|
||||
this.becca.etapiTokens[this.etapiTokenId] = this;
|
||||
if (this.etapiTokenId) {
|
||||
this.becca.etapiTokens[this.etapiTokenId] = this;
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
@@ -1127,6 +1127,7 @@ class Note extends AbstractEntity {
|
||||
if (this.isProtected && !this.isDecrypted && protectedSessionService.isProtectedSessionAvailable()) {
|
||||
try {
|
||||
this.title = protectedSessionService.decryptString(this.title);
|
||||
this.flatTextCache = null;
|
||||
|
||||
this.isDecrypted = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const appInfo = require('../services/app_info');
|
||||
const eu = require("./etapi_utils.js");
|
||||
|
||||
function register(router) {
|
||||
eu.route(router, 'get', '/etapi/app-info', (req, res, next) => {
|
||||
res.status(200).json(appInfo);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
register
|
||||
};
|
||||
@@ -589,6 +589,24 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/app-info:
|
||||
get:
|
||||
description: returns information about the running Trilium instance
|
||||
operationId: getAppInfo
|
||||
responses:
|
||||
'200':
|
||||
description: app info
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AppInfo'
|
||||
default:
|
||||
description: unexpected error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
EtapiTokenAuth:
|
||||
@@ -777,6 +795,48 @@ components:
|
||||
type: string
|
||||
pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z'
|
||||
example: 2021-12-31 19:18:11.939Z
|
||||
AppInfo:
|
||||
type: object
|
||||
required:
|
||||
- statu
|
||||
- code
|
||||
- message
|
||||
properties:
|
||||
appVersion:
|
||||
type: string
|
||||
description: Trilium version
|
||||
example: 0.50.2
|
||||
dbVersion:
|
||||
type: integer
|
||||
format: int32
|
||||
description: DB version
|
||||
example: 194
|
||||
syncVersion:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Sync protocol version
|
||||
example: 25
|
||||
buildDate:
|
||||
type: string
|
||||
format: date-time
|
||||
description: build date
|
||||
example: 2022-02-09T22:52:36+01:00
|
||||
buildRevision:
|
||||
type: string
|
||||
description: git build revision
|
||||
example: 23daaa2387a0655685377f0a541d154aeec2aae8
|
||||
dataDirectory:
|
||||
type: string
|
||||
description: data directory where Trilium stores files
|
||||
example: /home/user/data
|
||||
clipperProtocolVersion:
|
||||
type: string
|
||||
description: version of the supported Trilium Web Clipper protocol
|
||||
example: 1.0
|
||||
utcDateTime:
|
||||
type: string
|
||||
description: current UTC date time
|
||||
example: 2022-03-07T21:54:25.277Z
|
||||
Error:
|
||||
type: object
|
||||
required:
|
||||
|
||||
+3
-3
@@ -46,7 +46,7 @@ function register(router) {
|
||||
'mime': [v.notNull, v.isString],
|
||||
'content': [v.notNull, v.isString],
|
||||
'notePosition': [v.notNull, v.isInteger],
|
||||
'prefix': [v.notNull, v.isInteger],
|
||||
'prefix': [v.notNull, v.isString],
|
||||
'isExpanded': [v.notNull, v.isBoolean],
|
||||
'noteId': [v.notNull, v.isValidEntityId],
|
||||
'branchId': [v.notNull, v.isValidEntityId],
|
||||
@@ -162,7 +162,7 @@ function parseBoolean(obj, name) {
|
||||
return obj[name] === 'true';
|
||||
}
|
||||
|
||||
function parseInteger(obj, name) {
|
||||
function parseOrderDirection(obj, name) {
|
||||
if (!(name in obj)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -176,7 +176,7 @@ function parseInteger(obj, name) {
|
||||
return integer;
|
||||
}
|
||||
|
||||
function parseOrderDirection(obj, name) {
|
||||
function parseInteger(obj, name) {
|
||||
if (!(name in obj)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -13,16 +13,32 @@ const TPL = `
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<h4>Database integrity check</h4>
|
||||
|
||||
<p>This will check that the database is not corrupted on the SQLite level. It might take some time, depending on the DB size.</p>
|
||||
|
||||
<button id="check-integrity-button" class="btn">Check database integrity</button><br/><br/>
|
||||
|
||||
<h4>Consistency checks</h4>
|
||||
|
||||
<button id="find-and-fix-consistency-issues-button" class="btn">Find and fix consistency issues</button><br/><br/>
|
||||
|
||||
<h4>Anonymize database</h4>
|
||||
|
||||
<h5>Full anonymization</h5>
|
||||
|
||||
<p>This action will create a new copy of the database and anonymize it (remove all note content and leave only structure and some non-sensitive metadata)
|
||||
for sharing online for debugging purposes without fear of leaking your personal data.</p>
|
||||
|
||||
<button id="anonymize-button" class="btn">Save anonymized database</button><br/><br/>
|
||||
<button id="anonymize-full-button" class="btn">Save fully anonymized database</button><br/><br/>
|
||||
|
||||
<h5>Light anonymization</h5>
|
||||
|
||||
<p>This action will create a new copy of the database and do a light anonymization on it - specifically only content of all notes will be removed, but titles and attributes will remaing. Additionally, custom JS frontend/backend script notes and custom widgets will remain. This provides more context to debug the issues.</p>
|
||||
|
||||
<p>You can decide yourself if you want to provide fully or lightly anonymized database. Even fully anonymized DB is very useful, however in some cases lightly anonymized database can speed up the process of bug identification and fixing.</p>
|
||||
|
||||
<button id="anonymize-light-button" class="btn">Save lightly anonymized database</button><br/><br/>
|
||||
|
||||
<h4>Vacuum database</h4>
|
||||
|
||||
@@ -36,10 +52,11 @@ export default class AdvancedOptions {
|
||||
|
||||
this.$forceFullSyncButton = $("#force-full-sync-button");
|
||||
this.$fillEntityChangesButton = $("#fill-entity-changes-button");
|
||||
this.$anonymizeButton = $("#anonymize-button");
|
||||
this.$backupDatabaseButton = $("#backup-database-button");
|
||||
this.$anonymizeFullButton = $("#anonymize-full-button");
|
||||
this.$anonymizeLightButton = $("#anonymize-light-button");
|
||||
this.$vacuumDatabaseButton = $("#vacuum-database-button");
|
||||
this.$findAndFixConsistencyIssuesButton = $("#find-and-fix-consistency-issues-button");
|
||||
this.$checkIntegrityButton = $("#check-integrity-button");
|
||||
|
||||
this.$forceFullSyncButton.on('click', async () => {
|
||||
await server.post('sync/force-full-sync');
|
||||
@@ -53,14 +70,25 @@ export default class AdvancedOptions {
|
||||
toastService.showMessage("Sync rows filled successfully");
|
||||
});
|
||||
|
||||
this.$anonymizeButton.on('click', async () => {
|
||||
const resp = await server.post('database/anonymize');
|
||||
this.$anonymizeFullButton.on('click', async () => {
|
||||
const resp = await server.post('database/anonymize/full');
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError("Could not create anonymized database, check backend logs for details");
|
||||
}
|
||||
else {
|
||||
toastService.showMessage(`Created anonymized database in ${resp.anonymizedFilePath}`, 10000);
|
||||
toastService.showMessage(`Created fully anonymized database in ${resp.anonymizedFilePath}`, 10000);
|
||||
}
|
||||
});
|
||||
|
||||
this.$anonymizeLightButton.on('click', async () => {
|
||||
const resp = await server.post('database/anonymize/light');
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError("Could not create anonymized database, check backend logs for details");
|
||||
}
|
||||
else {
|
||||
toastService.showMessage(`Created lightly anonymized database in ${resp.anonymizedFilePath}`, 10000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,5 +103,16 @@ export default class AdvancedOptions {
|
||||
|
||||
toastService.showMessage("Consistency issues should be fixed.");
|
||||
});
|
||||
|
||||
this.$checkIntegrityButton.on('click', async () => {
|
||||
const {results} = await server.get('database/check-integrity');
|
||||
|
||||
if (results.length === 1 && results[0].integrity_check === "ok") {
|
||||
toastService.showMessage("Integrity check succeeded - no problems found.");
|
||||
}
|
||||
else {
|
||||
toastService.showMessage("Integrity check failed: " + JSON.stringify(results, null, 2), 15000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,46 +48,46 @@ const TPL = `
|
||||
export default class EtapiOptions {
|
||||
constructor() {
|
||||
$("#options-etapi").html(TPL);
|
||||
|
||||
|
||||
$("#create-etapi-token").on("click", async () => {
|
||||
const promptDialog = await import('../../dialogs/prompt.js');
|
||||
const tokenName = await promptDialog.ask({
|
||||
title: "New ETAPI token",
|
||||
message: "Please enter new token's name",
|
||||
defaultValue: "new token"
|
||||
message: "Please enter new token's name",
|
||||
defaultValue: "new token"
|
||||
});
|
||||
|
||||
|
||||
if (!tokenName.trim()) {
|
||||
alert("Token name can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const {token} = await server.post('etapi-tokens', {tokenName});
|
||||
|
||||
await promptDialog.ask({
|
||||
const {authToken} = await server.post('etapi-tokens', {tokenName});
|
||||
|
||||
await promptDialog.ask({
|
||||
title: "ETAPI token created",
|
||||
message: 'Copy the created token into clipboard. Trilium stores the token hashed and this is the last time you see it.',
|
||||
defaultValue: token
|
||||
message: 'Copy the created token into clipboard. Trilium stores the token hashed and this is the last time you see it.',
|
||||
defaultValue: authToken
|
||||
});
|
||||
|
||||
|
||||
this.refreshTokens();
|
||||
});
|
||||
|
||||
this.refreshTokens();
|
||||
}
|
||||
|
||||
|
||||
async refreshTokens() {
|
||||
const $noTokensYet = $("#no-tokens-yet");
|
||||
const $tokensTable = $("#tokens-table");
|
||||
|
||||
|
||||
const tokens = await server.get('etapi-tokens');
|
||||
|
||||
|
||||
$noTokensYet.toggle(tokens.length === 0);
|
||||
$tokensTable.toggle(tokens.length > 0);
|
||||
|
||||
const $tokensTableBody = $tokensTable.find("tbody");
|
||||
$tokensTableBody.empty();
|
||||
|
||||
|
||||
for (const token of tokens) {
|
||||
$tokensTableBody.append(
|
||||
$("<tr>")
|
||||
@@ -112,7 +112,7 @@ export default class EtapiOptions {
|
||||
});
|
||||
|
||||
await server.patch(`etapi-tokens/${etapiTokenId}`, {name: tokenName});
|
||||
|
||||
|
||||
this.refreshTokens();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ let shownCb;
|
||||
|
||||
export function ask({ title, message, defaultValue, shown }) {
|
||||
shownCb = shown;
|
||||
|
||||
|
||||
$("#prompt-title").text(title || "Prompt");
|
||||
|
||||
|
||||
$question = $("<label>")
|
||||
.prop("for", "prompt-dialog-answer")
|
||||
.text(message);
|
||||
@@ -51,7 +51,8 @@ $dialog.on("hidden.bs.modal", () => {
|
||||
}
|
||||
});
|
||||
|
||||
$form.on('submit', () => {
|
||||
$form.on('submit', e => {
|
||||
e.preventDefault();
|
||||
resolve($answer.val());
|
||||
|
||||
$dialog.modal('hide');
|
||||
|
||||
@@ -7,11 +7,14 @@ import treeService from "./tree.js";
|
||||
import utils from "./utils.js";
|
||||
import NoteContext from "./note_context.js";
|
||||
import appContext from "./app_context.js";
|
||||
import Mutex from "../utils/mutex.js";
|
||||
|
||||
export default class TabManager extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.mutex = new Mutex();
|
||||
|
||||
this.activeNtxId = null;
|
||||
|
||||
// elements are arrays of note contexts for each tab (one main context + subcontexts [splits])
|
||||
@@ -292,51 +295,55 @@ export default class TabManager extends Component {
|
||||
}
|
||||
|
||||
async removeNoteContext(ntxId) {
|
||||
const noteContextToRemove = this.getNoteContextById(ntxId);
|
||||
// removing note context is async process which can take some time, if users presses CTRL-W quickly, two
|
||||
// close events could interleave which would then lead to attempting to activate already removed context.
|
||||
await this.mutex.runExclusively(async () => {
|
||||
const noteContextToRemove = this.getNoteContextById(ntxId);
|
||||
|
||||
if (noteContextToRemove.isMainContext()) {
|
||||
// forbid removing last main note context
|
||||
// this was previously allowed (was replaced with empty tab) but this proved to be prone to race conditions
|
||||
const mainNoteContexts = this.getNoteContexts().filter(nc => nc.isMainContext());
|
||||
if (noteContextToRemove.isMainContext()) {
|
||||
// forbid removing last main note context
|
||||
// this was previously allowed (was replaced with empty tab) but this proved to be prone to race conditions
|
||||
const mainNoteContexts = this.getNoteContexts().filter(nc => nc.isMainContext());
|
||||
|
||||
if (mainNoteContexts.length === 1) {
|
||||
mainNoteContexts[0].setEmpty();
|
||||
return;
|
||||
if (mainNoteContexts.length === 1) {
|
||||
mainNoteContexts[0].setEmpty();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// close dangling autocompletes after closing the tab
|
||||
$(".aa-input").autocomplete("close");
|
||||
// close dangling autocompletes after closing the tab
|
||||
$(".aa-input").autocomplete("close");
|
||||
|
||||
const noteContextsToRemove = noteContextToRemove.getSubContexts();
|
||||
const ntxIdsToRemove = noteContextsToRemove.map(nc => nc.ntxId);
|
||||
const noteContextsToRemove = noteContextToRemove.getSubContexts();
|
||||
const ntxIdsToRemove = noteContextsToRemove.map(nc => nc.ntxId);
|
||||
|
||||
await this.triggerEvent('beforeTabRemove', { ntxIds: ntxIdsToRemove });
|
||||
await this.triggerEvent('beforeTabRemove', { ntxIds: ntxIdsToRemove });
|
||||
|
||||
if (!noteContextToRemove.isMainContext()) {
|
||||
await this.activateNoteContext(noteContextToRemove.getMainContext().ntxId);
|
||||
}
|
||||
else if (this.mainNoteContexts.length <= 1) {
|
||||
await this.openAndActivateEmptyTab();
|
||||
}
|
||||
else if (ntxIdsToRemove.includes(this.activeNtxId)) {
|
||||
const idx = this.mainNoteContexts.findIndex(nc => nc.ntxId === noteContextToRemove.ntxId);
|
||||
|
||||
if (idx === this.mainNoteContexts.length - 1) {
|
||||
await this.activatePreviousTabCommand();
|
||||
if (!noteContextToRemove.isMainContext()) {
|
||||
await this.activateNoteContext(noteContextToRemove.getMainContext().ntxId);
|
||||
}
|
||||
else {
|
||||
await this.activateNextTabCommand();
|
||||
else if (this.mainNoteContexts.length <= 1) {
|
||||
await this.openAndActivateEmptyTab();
|
||||
}
|
||||
}
|
||||
else if (ntxIdsToRemove.includes(this.activeNtxId)) {
|
||||
const idx = this.mainNoteContexts.findIndex(nc => nc.ntxId === noteContextToRemove.ntxId);
|
||||
|
||||
this.children = this.children.filter(nc => !ntxIdsToRemove.includes(nc.ntxId));
|
||||
if (idx === this.mainNoteContexts.length - 1) {
|
||||
await this.activatePreviousTabCommand();
|
||||
}
|
||||
else {
|
||||
await this.activateNextTabCommand();
|
||||
}
|
||||
}
|
||||
|
||||
this.recentlyClosedTabs.push(noteContextsToRemove);
|
||||
this.children = this.children.filter(nc => !ntxIdsToRemove.includes(nc.ntxId));
|
||||
|
||||
this.triggerEvent('noteContextRemoved', {ntxIds: ntxIdsToRemove});
|
||||
this.recentlyClosedTabs.push(noteContextsToRemove);
|
||||
|
||||
this.tabsUpdate.scheduleUpdate();
|
||||
this.triggerEvent('noteContextRemoved', {ntxIds: ntxIdsToRemove});
|
||||
|
||||
this.tabsUpdate.scheduleUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
tabReorderEvent({ntxIdsInOrder}) {
|
||||
|
||||
@@ -65,7 +65,7 @@ async function resolveNotePathToSegments(notePath, hoistedNoteId = 'root', logEr
|
||||
|
||||
if (!parents.length) {
|
||||
if (logErrors) {
|
||||
ws.logError(`No parents found for ${childNoteId} (${child.title}) for path ${notePath}`);
|
||||
ws.logError(`No parents found for note ${childNoteId} (${child.title}) for path ${notePath}`);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export default class Mutex {
|
||||
constructor() {
|
||||
this.current = Promise.resolve();
|
||||
}
|
||||
|
||||
/** @returns {Promise} */
|
||||
lock() {
|
||||
let resolveFun;
|
||||
const subPromise = new Promise(resolve => resolveFun = () => resolve());
|
||||
// Caller gets a promise that resolves when the current outstanding lock resolves
|
||||
const newPromise = this.current.then(() => resolveFun);
|
||||
// Don't allow the next request until the new promise is done
|
||||
this.current = subPromise;
|
||||
// Return the new promise
|
||||
return newPromise;
|
||||
};
|
||||
|
||||
async runExclusively(cb) {
|
||||
const unlock = await this.lock();
|
||||
|
||||
try {
|
||||
await cb();
|
||||
}
|
||||
finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,10 @@ const ATTR_HELP = {
|
||||
"shareAlias": "define an alias using which the note will be available under https://your_trilium_host/share/[your_alias]",
|
||||
"shareOmitDefaultCss": "default share page CSS will be omitted. Use when you make extensive styling changes.",
|
||||
"shareRoot": "marks note which is served on /share root.",
|
||||
"shareRaw": "note will be served in its raw format, without HTML wrapper",
|
||||
"shareDisallowRobotIndexing": `will forbid robot indexing of this note via <code>X-Robots-Tag: noindex</code> header`,
|
||||
"displayRelations": "comma delimited names of relations which should be displayed. All other ones will be hidden.",
|
||||
"hideRelations": "comma delimited names of relations which should be hidden. All other ones will be displayed.",
|
||||
},
|
||||
"relation": {
|
||||
"runOnNoteCreation": "executes when note is created on backend",
|
||||
|
||||
@@ -497,6 +497,16 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget {
|
||||
await this.renderOwnedAttributes(attributes, false);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$editor.trigger('focus');
|
||||
|
||||
this.textEditor.model.change( writer => {
|
||||
const positionAt = writer.createPositionAt(this.textEditor.model.document.getRoot(), 'end');
|
||||
|
||||
writer.setSelection(positionAt);
|
||||
} );
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({loadResults}) {
|
||||
if (loadResults.getAttributes(this.componentId).find(attr => attributeService.isAffecting(attr, this.note))) {
|
||||
this.refresh();
|
||||
|
||||
@@ -186,7 +186,9 @@ export default class RibbonContainer extends NoteContextAwareWidget {
|
||||
const activeChild = this.getActiveRibbonWidget();
|
||||
|
||||
if (activeChild && (refreshActiveTab || !wasAlreadyActive)) {
|
||||
activeChild.handleEvent('noteSwitched', {noteContext: this.noteContext, notePath: this.notePath});
|
||||
activeChild.handleEvent('noteSwitched', {noteContext: this.noteContext, notePath: this.notePath}).then(() => {
|
||||
activeChild.focus?.();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.lastActiveComponentId = null;
|
||||
|
||||
@@ -17,11 +17,12 @@ const TPL = `<div class="mermaid-widget">
|
||||
.mermaid-render {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mermaid-error alert alert-warning">
|
||||
<p><strong>The diagram could not displayed. See <a href="https://mermaid-js.github.io/mermaid/#/flowchart?id=graph">help and examples</a>.</strong></p>
|
||||
<p><strong>The diagram could not be displayed. See <a href="https://mermaid-js.github.io/mermaid/#/flowchart?id=graph">help and examples</a>.</strong></p>
|
||||
<p class="error-content"></p>
|
||||
</div>
|
||||
|
||||
@@ -70,8 +71,25 @@ export default class MermaidWidget extends NoteContextAwareWidget {
|
||||
|
||||
this.$display.empty();
|
||||
|
||||
const libLoaded = libraryLoader.requireLibrary(libraryLoader.WHEEL_ZOOM);
|
||||
|
||||
try {
|
||||
mermaid.mermaidAPI.render('mermaid-graph-' + idCounter++, content, content => this.$display.html(content));
|
||||
const idNumber = idCounter++;
|
||||
|
||||
mermaid.mermaidAPI.render('mermaid-graph-' + idNumber, content, async content => {
|
||||
this.$display.html(content);
|
||||
|
||||
await libLoaded;
|
||||
|
||||
this.$display.attr("id", 'mermaid-render-' + idNumber);
|
||||
|
||||
WZoom.create('#mermaid-render-' + idNumber, {
|
||||
type: 'html',
|
||||
maxScale: 10,
|
||||
speed: 20,
|
||||
zoomOnClick: false
|
||||
});
|
||||
});
|
||||
|
||||
this.$errorContainer.hide();
|
||||
} catch (e) {
|
||||
|
||||
@@ -275,7 +275,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
|
||||
|
||||
const label = attrs.find(attr =>
|
||||
attr.type === 'label'
|
||||
&& ['readOnly', 'autoReadOnlyDisabled', 'cssClass', 'displayRelations'].includes(attr.name)
|
||||
&& ['readOnly', 'autoReadOnlyDisabled', 'cssClass', 'displayRelations', 'hideRelations'].includes(attr.name)
|
||||
&& attributeService.isAffecting(attr, this.note));
|
||||
|
||||
const relation = attrs.find(attr =>
|
||||
|
||||
@@ -82,4 +82,8 @@ export default class OwnedAttributeListWidget extends NoteContextAwareWidget {
|
||||
this.getTitle(this.note);
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.attributeEditorWidget.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +310,10 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
|
||||
$attr.attr("data-attribute-id", result.attributeId);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$widget.find(".promoted-attribute-input:first").focus();
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({loadResults}) {
|
||||
if (loadResults.getAttributes(this.componentId).find(attr => attributeService.isAffecting(attr, this.note))) {
|
||||
this.refresh();
|
||||
|
||||
@@ -23,6 +23,7 @@ import Limit from "../search_options/limit.js";
|
||||
import DeleteNoteRevisionsSearchAction from "../search_actions/delete_note_revisions.js";
|
||||
import Debug from "../search_options/debug.js";
|
||||
import appContext from "../../services/app_context.js";
|
||||
import MoveNoteSearchAction from "../search_actions/move_note.js";
|
||||
|
||||
const TPL = `
|
||||
<div class="search-definition-widget">
|
||||
@@ -127,10 +128,14 @@ const TPL = `
|
||||
action
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="#" data-action-add="moveNote">
|
||||
Move note</a>
|
||||
<a class="dropdown-item" href="#" data-action-add="deleteNote">
|
||||
Delete note</a>
|
||||
<a class="dropdown-item" href="#" data-action-add="deleteNoteRevisions">
|
||||
Delete note revisions</a>
|
||||
<a class="dropdown-item" href="#" data-action-add="moveNote">
|
||||
Delete note revisions</a>
|
||||
<a class="dropdown-item" href="#" data-action-add="deleteLabel">
|
||||
Delete label</a>
|
||||
<a class="dropdown-item" href="#" data-action-add="deleteRelation">
|
||||
@@ -193,6 +198,7 @@ const OPTION_CLASSES = [
|
||||
const ACTION_CLASSES = {};
|
||||
|
||||
for (const clazz of [
|
||||
MoveNoteSearchAction,
|
||||
DeleteNoteSearchAction,
|
||||
DeleteNoteRevisionsSearchAction,
|
||||
DeleteLabelSearchAction,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import SpacedUpdate from "../../services/spaced_update.js";
|
||||
import AbstractSearchAction from "./abstract_search_action.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
|
||||
const TPL = `
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="margin-right: 10px;" class="text-nowrap">Move note</div>
|
||||
|
||||
<div style="margin-right: 10px;" class="text-nowrap">to</div>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control target-parent-note" placeholder="target parent note"/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
<span class="bx bx-help-circle icon-action" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
|
||||
<div class="dropdown-menu dropdown-menu-right p-4">
|
||||
<p>On all matched notes:</p>
|
||||
|
||||
<ul>
|
||||
<li>move note to the new parent if note has only one parent (i.e. the old placement is removed and new placement into the new parent is created)</li>
|
||||
<li>clone note to the new parent if note has multiple clones/placements (it's not clear which placement should be removed)</li>
|
||||
<li>nothing will happen if note cannot be moved to the target note (i.e. this would create a tree cycle)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="bx bx-x icon-action action-conf-del"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
export default class MoveNoteSearchAction extends AbstractSearchAction {
|
||||
static get actionName() { return "moveNote"; }
|
||||
|
||||
doRender() {
|
||||
const $action = $(TPL);
|
||||
|
||||
const $targetParentNote = $action.find('.target-parent-note');
|
||||
noteAutocompleteService.initNoteAutocomplete($targetParentNote);
|
||||
$targetParentNote.setNote(this.actionDef.targetParentNoteId);
|
||||
|
||||
$targetParentNote.on('autocomplete:closed', () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
const spacedUpdate = new SpacedUpdate(async () => {
|
||||
await this.saveAction({
|
||||
targetParentNoteId: $targetParentNote.getSelectedNoteId()
|
||||
});
|
||||
}, 1000)
|
||||
|
||||
$targetParentNote.on('input', () => spacedUpdate.scheduleUpdate());
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const TPL = `
|
||||
<tr>
|
||||
<td class="title-column">Search string:</td>
|
||||
<td>
|
||||
<input type="text" class="form-control search-string" placeholder="fulltext keywords, #tag = value ...">
|
||||
<textarea class="form-control search-string" placeholder="fulltext keywords, #tag = value ..."></textarea>
|
||||
</td>
|
||||
<td class="button-column">
|
||||
<div class="dropdown help-dropdown">
|
||||
@@ -46,6 +46,9 @@ export default class SearchString extends AbstractSearchOption {
|
||||
this.$searchString.on('input', () => this.spacedUpdate.scheduleUpdate());
|
||||
|
||||
utils.bindElShortcut(this.$searchString, 'return', async () => {
|
||||
// this also in effect disallows new lines in query string.
|
||||
// on one hand this makes sense since search string is a label
|
||||
// on the other hand it could be nice for structuring long search string. It's probably a niche case though.
|
||||
await this.spacedUpdate.updateNowIfNecessary();
|
||||
|
||||
this.triggerCommand('refreshResults');
|
||||
|
||||
@@ -49,6 +49,7 @@ class ImageTypeWidget extends TypeWidget {
|
||||
libraryLoader.requireLibrary(libraryLoader.WHEEL_ZOOM).then(() => {
|
||||
WZoom.create('#' + this.$imageView.attr("id"), {
|
||||
maxScale: 10,
|
||||
speed: 20,
|
||||
zoomOnClick: false
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
User-agent: *
|
||||
Allow: /share/
|
||||
Disallow: /
|
||||
|
||||
@@ -91,6 +91,10 @@ body {
|
||||
|
||||
--ck-color-table-focused-cell-background: var(--more-accented-background-color);
|
||||
|
||||
--ck-color-labeled-field-label-background: var(--accented-background-color);
|
||||
|
||||
--ck-color-editable-blur-selection: var(--accented-background-color);
|
||||
|
||||
/* todo lists */
|
||||
|
||||
--ck-color-todo-list-checkmark-border: var(--main-border-color);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
body {
|
||||
font-family: 'Lucida Grande', 'Lucida Sans Unicode', arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#layout {
|
||||
|
||||
@@ -7,6 +7,8 @@ const treeService = require('../../services/tree');
|
||||
const noteService = require('../../services/notes');
|
||||
const becca = require('../../becca/becca');
|
||||
const TaskContext = require('../../services/task_context');
|
||||
const branchService = require("../../services/branches");
|
||||
const log = require("../../services/log.js");
|
||||
|
||||
/**
|
||||
* Code in this file deals with moving and cloning branches. Relationship between note and parent note is unique
|
||||
@@ -23,29 +25,7 @@ function moveBranchToParent(req) {
|
||||
return [400, `One or both branches ${branchId}, ${parentBranchId} have not been found`];
|
||||
}
|
||||
|
||||
if (branchToMove.parentNoteId === parentBranch.noteId) {
|
||||
return { success: true }; // no-op
|
||||
}
|
||||
|
||||
const validationResult = treeService.validateParentChild(parentBranch.noteId, branchToMove.noteId, branchId);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return [200, validationResult];
|
||||
}
|
||||
|
||||
const maxNotePos = sql.getValue('SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0', [parentBranch.noteId]);
|
||||
const newNotePos = maxNotePos === null ? 0 : maxNotePos + 10;
|
||||
|
||||
// expanding so that the new placement of the branch is immediately visible
|
||||
parentBranch.isExpanded = true;
|
||||
parentBranch.save();
|
||||
|
||||
const newBranch = branchToMove.createClone(parentBranch.noteId, newNotePos);
|
||||
newBranch.save();
|
||||
|
||||
branchToMove.markAsDeleted();
|
||||
|
||||
return { success: true };
|
||||
return branchService.moveBranchToBranch(branchToMove, parentBranch, branchId);
|
||||
}
|
||||
|
||||
function moveBranchBeforeNote(req) {
|
||||
@@ -101,6 +81,8 @@ function moveBranchBeforeNote(req) {
|
||||
// if sorting is not needed then still the ordering might have changed above manually
|
||||
entityChangesService.addNoteReorderingEntityChange(parentNote.noteId);
|
||||
|
||||
log.info(`Moved note ${branchToMove.noteId}, branch ${branchId} before note ${beforeBranch.noteId}, branch ${beforeBranchId}`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -150,6 +132,8 @@ function moveBranchAfterNote(req) {
|
||||
// if sorting is not needed then still the ordering might have changed above manually
|
||||
entityChangesService.addNoteReorderingEntityChange(parentNote.noteId);
|
||||
|
||||
log.info(`Moved note ${branchToMove.noteId}, branch ${branchId} after note ${afterNote.noteId}, branch ${afterBranchId}`);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -138,12 +138,15 @@ function processContent(images, note, content) {
|
||||
value: imageNote.noteId
|
||||
}).save();
|
||||
|
||||
console.log(`Replacing ${imageId} with ${url}`);
|
||||
log.info(`Replacing ${imageId} with ${url} in note ${note.noteId}`);
|
||||
|
||||
rewrittenContent = utils.replaceAll(rewrittenContent, imageId, url);
|
||||
}
|
||||
}
|
||||
|
||||
// fallback if parsing/downloading images fails for some reason on the extension side (
|
||||
rewrittenContent = noteService.downloadImages(note.noteId, rewrittenContent);
|
||||
|
||||
return rewrittenContent;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ const backupService = require('../../services/backup');
|
||||
const anonymizationService = require('../../services/anonymization');
|
||||
const consistencyChecksService = require('../../services/consistency_checks');
|
||||
|
||||
async function anonymize() {
|
||||
return await anonymizationService.createAnonymizedCopy();
|
||||
async function anonymize(req) {
|
||||
return await anonymizationService.createAnonymizedCopy(req.params.type);
|
||||
}
|
||||
|
||||
async function backupDatabase() {
|
||||
@@ -22,6 +22,16 @@ function vacuumDatabase() {
|
||||
log.info("Database has been vacuumed.");
|
||||
}
|
||||
|
||||
function checkIntegrity() {
|
||||
const results = sql.getRows("PRAGMA integrity_check");
|
||||
|
||||
log.info("Integrity check result: " + JSON.stringify(results));
|
||||
|
||||
return {
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
function findAndFixConsistencyIssues() {
|
||||
consistencyChecksService.runOnDemandChecks(true);
|
||||
}
|
||||
@@ -30,5 +40,6 @@ module.exports = {
|
||||
backupDatabase,
|
||||
vacuumDatabase,
|
||||
findAndFixConsistencyIssues,
|
||||
anonymize
|
||||
anonymize,
|
||||
checkIntegrity
|
||||
};
|
||||
|
||||
@@ -2,16 +2,14 @@ const etapiTokenService = require("../../services/etapi_tokens");
|
||||
|
||||
function getTokens() {
|
||||
const tokens = etapiTokenService.getTokens();
|
||||
|
||||
|
||||
tokens.sort((a, b) => a.utcDateCreated < b.utcDateCreated ? -1 : 1);
|
||||
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function createToken(req) {
|
||||
return {
|
||||
authToken: etapiTokenService.createToken(req.body.tokenName)
|
||||
};
|
||||
return etapiTokenService.createToken(req.body.tokenName);
|
||||
}
|
||||
|
||||
function patchToken(req) {
|
||||
@@ -27,4 +25,4 @@ module.exports = {
|
||||
createToken,
|
||||
patchToken,
|
||||
deleteToken
|
||||
};
|
||||
};
|
||||
|
||||
@@ -153,7 +153,10 @@ function getRelationMap(req) {
|
||||
.split(",")
|
||||
.map(token => token.trim());
|
||||
|
||||
console.log("displayRelations", displayRelations);
|
||||
const hideRelationsVal = relationMapNote.getLabelValue('hideRelations');
|
||||
const hideRelations = !hideRelationsVal ? [] : hideRelationsVal
|
||||
.split(",")
|
||||
.map(token => token.trim());
|
||||
|
||||
const foundNoteIds = sql.getColumn(`SELECT noteId FROM notes WHERE isDeleted = 0 AND noteId IN (${questionMarks})`, noteIds);
|
||||
const notes = becca.getNotes(foundNoteIds);
|
||||
@@ -163,7 +166,9 @@ function getRelationMap(req) {
|
||||
|
||||
resp.relations = resp.relations.concat(note.getRelations()
|
||||
.filter(relation => !relation.isAutoLink() || displayRelations.includes(relation.name))
|
||||
.filter(relation => displayRelations.length === 0 || displayRelations.includes(relation.name))
|
||||
.filter(relation => displayRelations.length > 0
|
||||
? displayRelations.includes(relation.name)
|
||||
: !hideRelations.includes(relation.name))
|
||||
.filter(relation => noteIds.includes(relation.value))
|
||||
.map(relation => ({
|
||||
attributeId: relation.attributeId,
|
||||
|
||||
@@ -6,6 +6,8 @@ const log = require('../../services/log');
|
||||
const scriptService = require('../../services/script');
|
||||
const searchService = require('../../services/search/services/search');
|
||||
const noteRevisionService = require("../../services/note_revisions");
|
||||
const branchService = require("../../services/branches");
|
||||
const cloningService = require("../../services/cloning");
|
||||
const {formatAttrForSearch} = require("../../services/attribute_formatter");
|
||||
|
||||
async function searchFromNoteInt(note) {
|
||||
@@ -92,6 +94,26 @@ const ACTION_HANDLERS = {
|
||||
setRelationTarget: (action, note) => {
|
||||
note.setRelation(action.relationName, action.targetNoteId);
|
||||
},
|
||||
moveNote: (action, note) => {
|
||||
const targetParentNote = becca.getNote(action.targetParentNoteId);
|
||||
|
||||
if (!targetParentNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
let res;
|
||||
|
||||
if (note.getParentBranches().length > 1) {
|
||||
res = cloningService.cloneNoteToNote(note.noteId, action.targetParentNoteId);
|
||||
}
|
||||
else {
|
||||
res = branchService.moveBranchToNote(note.getParentBranches()[0], action.targetParentNoteId);
|
||||
}
|
||||
|
||||
if (!res.success) {
|
||||
log.info(`Moving/cloning note ${note.noteId} to ${action.targetParentNoteId} failed with error ${JSON.stringify(res)}`);
|
||||
}
|
||||
},
|
||||
executeScript: (action, note) => {
|
||||
if (!action.script || !action.script.trim()) {
|
||||
log.info("Ignoring executeScript since the script is empty.")
|
||||
|
||||
@@ -29,6 +29,8 @@ function uploadImage(req) {
|
||||
}
|
||||
}
|
||||
|
||||
note.setLabel("sentFromSender");
|
||||
|
||||
return {
|
||||
noteId: noteId
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ const fontsRoute = require('./api/fonts');
|
||||
const etapiTokensApiRoutes = require('./api/etapi_tokens');
|
||||
const shareRoutes = require('../share/routes');
|
||||
const etapiAuthRoutes = require('../etapi/auth');
|
||||
const etapiAppInfoRoutes = require('../etapi/app_info');
|
||||
const etapiAttributeRoutes = require('../etapi/attributes');
|
||||
const etapiBranchRoutes = require('../etapi/branches');
|
||||
const etapiNoteRoutes = require('../etapi/notes');
|
||||
@@ -324,7 +325,7 @@ function register(app) {
|
||||
|
||||
apiRoute(GET, '/api/sql/schema', sqlRoute.getSchema);
|
||||
apiRoute(POST, '/api/sql/execute/:noteId', sqlRoute.execute);
|
||||
route(POST, '/api/database/anonymize', [auth.checkApiAuthOrElectron, csrfMiddleware], databaseRoute.anonymize, apiResultHandler, false);
|
||||
route(POST, '/api/database/anonymize/:type', [auth.checkApiAuthOrElectron, csrfMiddleware], databaseRoute.anonymize, apiResultHandler, false);
|
||||
|
||||
// backup requires execution outside of transaction
|
||||
route(POST, '/api/database/backup-database', [auth.checkApiAuthOrElectron, csrfMiddleware], databaseRoute.backupDatabase, apiResultHandler, false);
|
||||
@@ -334,6 +335,8 @@ function register(app) {
|
||||
|
||||
route(POST, '/api/database/find-and-fix-consistency-issues', [auth.checkApiAuthOrElectron, csrfMiddleware], databaseRoute.findAndFixConsistencyIssues, apiResultHandler, false);
|
||||
|
||||
apiRoute(GET, '/api/database/check-integrity', databaseRoute.checkIntegrity);
|
||||
|
||||
apiRoute(POST, '/api/script/exec', scriptRoute.exec);
|
||||
apiRoute(POST, '/api/script/run/:noteId', scriptRoute.run);
|
||||
apiRoute(GET, '/api/script/startup', scriptRoute.getStartupBundles);
|
||||
@@ -387,8 +390,9 @@ function register(app) {
|
||||
apiRoute(DELETE, '/api/etapi-tokens/:etapiTokenId', etapiTokensApiRoutes.deleteToken);
|
||||
|
||||
shareRoutes.register(router);
|
||||
|
||||
|
||||
etapiAuthRoutes.register(router);
|
||||
etapiAppInfoRoutes.register(router);
|
||||
etapiAttributeRoutes.register(router);
|
||||
etapiBranchRoutes.register(router);
|
||||
etapiNoteRoutes.register(router);
|
||||
|
||||
@@ -5,7 +5,7 @@ const dateUtils = require("./date_utils");
|
||||
const Database = require("better-sqlite3");
|
||||
const sql = require("./sql");
|
||||
|
||||
function getAnonymizationScript() {
|
||||
function getFullAnonymizationScript() {
|
||||
// we want to delete all non-builtin attributes because they can contain sensitive names and values
|
||||
// on the other hand builtin/system attrs should not contain any sensitive info
|
||||
const builtinAttrNames = BUILTIN_ATTRIBUTES
|
||||
@@ -13,14 +13,14 @@ function getAnonymizationScript() {
|
||||
|
||||
const anonymizeScript = `
|
||||
UPDATE etapi_tokens SET tokenHash = 'API token hash value';
|
||||
UPDATE notes SET title = 'title';
|
||||
UPDATE notes SET title = 'title' WHERE title NOT IN ('root', 'hidden', 'share');
|
||||
UPDATE note_contents SET content = 'text' WHERE content IS NOT NULL;
|
||||
UPDATE note_revisions SET title = 'title';
|
||||
UPDATE note_revision_contents SET content = 'text' WHERE content IS NOT NULL;
|
||||
|
||||
UPDATE attributes SET name = 'name', value = 'value' WHERE type = 'label' AND name NOT IN(${builtinAttrNames});
|
||||
UPDATE attributes SET name = 'name' WHERE type = 'relation' AND name NOT IN (${builtinAttrNames});
|
||||
UPDATE branches SET prefix = 'prefix' WHERE prefix IS NOT NULL;
|
||||
UPDATE branches SET prefix = 'prefix' WHERE prefix IS NOT NULL AND prefix != 'recovered';
|
||||
UPDATE options SET value = 'anonymized' WHERE name IN
|
||||
('documentId', 'documentSecret', 'encryptedDataKey',
|
||||
'passwordVerificationHash', 'passwordVerificationSalt',
|
||||
@@ -33,18 +33,37 @@ VACUUM;
|
||||
return anonymizeScript;
|
||||
}
|
||||
|
||||
async function createAnonymizedCopy() {
|
||||
function getLightAnonymizationScript() {
|
||||
return `
|
||||
UPDATE note_contents SET content = 'text' WHERE content IS NOT NULL AND noteId NOT IN (
|
||||
SELECT noteId FROM notes WHERE mime IN ('application/javascript;env=backend', 'application/javascript;env=frontend')
|
||||
);
|
||||
UPDATE note_revision_contents SET content = 'text' WHERE content IS NOT NULL AND noteRevisionId NOT IN (
|
||||
SELECT noteRevisionId FROM note_revisions WHERE mime IN ('application/javascript;env=backend', 'application/javascript;env=frontend')
|
||||
);
|
||||
`;
|
||||
}
|
||||
|
||||
async function createAnonymizedCopy(type) {
|
||||
if (!['full', 'light'].includes(type)) {
|
||||
throw new Error(`Unrecognized anonymization type '${type}'`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dataDir.ANONYMIZED_DB_DIR)) {
|
||||
fs.mkdirSync(dataDir.ANONYMIZED_DB_DIR, 0o700);
|
||||
}
|
||||
|
||||
const anonymizedFile = dataDir.ANONYMIZED_DB_DIR + "/" + "anonymized-" + dateUtils.getDateTimeForFile() + ".db";
|
||||
const anonymizedFile = `${dataDir.ANONYMIZED_DB_DIR}/anonymized-${type}-${dateUtils.getDateTimeForFile()}.db`;
|
||||
|
||||
await sql.copyDatabase(anonymizedFile);
|
||||
|
||||
const db = new Database(anonymizedFile);
|
||||
|
||||
db.exec(getAnonymizationScript());
|
||||
const anonymizationScript = type === 'light'
|
||||
? getLightAnonymizationScript()
|
||||
: getFullAnonymizationScript();
|
||||
|
||||
db.exec(anonymizationScript);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -55,6 +74,6 @@ async function createAnonymizedCopy() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAnonymizationScript,
|
||||
getFullAnonymizationScript,
|
||||
createAnonymizedCopy
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ module.exports = {
|
||||
buildDate: build.buildDate,
|
||||
buildRevision: build.buildRevision,
|
||||
dataDirectory: TRILIUM_DATA_DIR,
|
||||
clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION
|
||||
clipperProtocolVersion: CLIPPER_PROTOCOL_VERSION,
|
||||
utcDateTime: new Date().toISOString() // for timezone inference
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ function getNotesWithLabel(name, value) {
|
||||
|
||||
// TODO: should be in search service
|
||||
/** @returns {Note|null} */
|
||||
function getNoteWithLabel(name, value) {
|
||||
function getNoteWithLabel(name, value = undefined) {
|
||||
// optimized version (~20 times faster) without using normal search, useful for e.g. finding date notes
|
||||
const attrs = becca.findAttributes('label', name);
|
||||
|
||||
@@ -128,10 +128,6 @@ function isAttributeDangerous(type, name) {
|
||||
);
|
||||
}
|
||||
|
||||
function getBuiltinAttributeNames() {
|
||||
return BUILTIN_ATTRIBUTES;
|
||||
}
|
||||
|
||||
function sanitizeAttributeName(origName) {
|
||||
let fixedName;
|
||||
|
||||
@@ -156,6 +152,5 @@ module.exports = {
|
||||
getAttributeNames,
|
||||
isAttributeType,
|
||||
isAttributeDangerous,
|
||||
getBuiltinAttributeNames,
|
||||
sanitizeAttributeName
|
||||
};
|
||||
|
||||
@@ -94,6 +94,12 @@ function reject(req, res, message) {
|
||||
function checkCredentials(req, res, next) {
|
||||
if (!sqlInit.isDbInitialized()) {
|
||||
res.status(400).send('Database is not initialized yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordService.isPasswordSet()) {
|
||||
res.status(400).send('Password has not been set yet. Please set a password and repeat the action');
|
||||
return;
|
||||
}
|
||||
|
||||
const header = req.headers['trilium-cred'] || '';
|
||||
|
||||
@@ -293,7 +293,7 @@ function BackendScriptApi(currentNote, apiParams) {
|
||||
*
|
||||
* @param message
|
||||
*/
|
||||
this.log = message => log.info(`Script "${currentNote.title}" (${currentNote.noteId}): ${message}`);
|
||||
this.log = message => log.info(message);
|
||||
|
||||
/**
|
||||
* Returns root note of the calendar.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const treeService = require("./tree.js");
|
||||
const sql = require("./sql.js");
|
||||
|
||||
function moveBranchToNote(sourceBranch, targetParentNoteId) {
|
||||
if (sourceBranch.parentNoteId === targetParentNoteId) {
|
||||
return {success: true}; // no-op
|
||||
}
|
||||
|
||||
const validationResult = treeService.validateParentChild(targetParentNoteId, sourceBranch.noteId, sourceBranch.branchId);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return [200, validationResult];
|
||||
}
|
||||
|
||||
const maxNotePos = sql.getValue('SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0', [targetParentNoteId]);
|
||||
const newNotePos = maxNotePos === null ? 0 : maxNotePos + 10;
|
||||
|
||||
const newBranch = sourceBranch.createClone(targetParentNoteId, newNotePos);
|
||||
newBranch.save();
|
||||
|
||||
sourceBranch.markAsDeleted();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branch: newBranch
|
||||
};
|
||||
}
|
||||
|
||||
function moveBranchToBranch(sourceBranch, targetParentBranch) {
|
||||
const res = moveBranchToNote(sourceBranch, targetParentBranch.noteId);
|
||||
|
||||
if (!res.success) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// expanding so that the new placement of the branch is immediately visible
|
||||
targetParentBranch.isExpanded = true;
|
||||
targetParentBranch.save();
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
moveBranchToBranch,
|
||||
moveBranchToNote
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
module.exports = { buildDate:"2022-01-19T20:14:40+01:00", buildRevision: "780b5205067623de0e3224bfeb571d6ccad9e6cd" };
|
||||
module.exports = { buildDate:"2022-04-10T14:13:51+02:00", buildRevision: "a04becc4ec653e21c2c80aa9d9ef5b7c9a8e1aa8" };
|
||||
|
||||
@@ -46,6 +46,10 @@ module.exports = [
|
||||
{ type: 'label', name: 'shareAlias' },
|
||||
{ type: 'label', name: 'shareOmitDefaultCss' },
|
||||
{ type: 'label', name: 'shareRoot' },
|
||||
{ type: 'label', name: 'shareRaw' },
|
||||
{ type: 'label', name: 'shareDisallowRobotIndexing' },
|
||||
{ type: 'label', name: 'displayRelations' },
|
||||
{ type: 'label', name: 'hideRelations' },
|
||||
|
||||
// relation names
|
||||
{ type: 'relation', name: 'internalLink' },
|
||||
|
||||
@@ -9,6 +9,7 @@ const TaskContext = require("./task_context");
|
||||
const utils = require('./utils');
|
||||
const becca = require("../becca/becca");
|
||||
const beccaService = require("../becca/becca_service");
|
||||
const log = require("./log");
|
||||
|
||||
function cloneNoteToNote(noteId, parentNoteId, prefix) {
|
||||
if (parentNoteId === 'share') {
|
||||
@@ -34,6 +35,8 @@ function cloneNoteToNote(noteId, parentNoteId, prefix) {
|
||||
isExpanded: 0
|
||||
}).save();
|
||||
|
||||
log.info(`Cloned note ${noteId} to new parent note ${parentNoteId} with prefix ${prefix}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branchId: branch.branchId,
|
||||
@@ -73,6 +76,8 @@ function ensureNoteIsPresentInParent(noteId, parentNoteId, prefix) {
|
||||
prefix: prefix,
|
||||
isExpanded: 0
|
||||
}).save();
|
||||
|
||||
log.info(`Ensured note ${noteId} is in parent note ${parentNoteId} with prefix ${prefix}`);
|
||||
}
|
||||
|
||||
function ensureNoteIsAbsentFromParent(noteId, parentNoteId) {
|
||||
@@ -80,8 +85,14 @@ function ensureNoteIsAbsentFromParent(noteId, parentNoteId) {
|
||||
const branch = becca.getBranch(branchId);
|
||||
|
||||
if (branch) {
|
||||
if (branch.getNote().getParentBranches().length <= 1) {
|
||||
throw new Error(`Cannot remove branch ${branch.branchId} between child ${noteId} and parent ${parentNoteId} because this would delete the note as well.`);
|
||||
}
|
||||
|
||||
const deleteId = utils.randomString(10);
|
||||
noteService.deleteBranch(branch, deleteId, new TaskContext());
|
||||
|
||||
log.info(`Ensured note ${noteId} is NOT in parent note ${parentNoteId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +132,8 @@ function cloneNoteAfter(noteId, afterBranchId) {
|
||||
isExpanded: 0
|
||||
}).save();
|
||||
|
||||
log.info(`Cloned note ${noteId} into parent note ${afterNote.parentNoteId} after note ${afterNote.noteId}, branch ${afterBranchId}`);
|
||||
|
||||
return { success: true, branchId: branch.branchId };
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ class ConsistencyChecks {
|
||||
|
||||
findLogicIssues() {
|
||||
const noteTypesStr = noteTypes.map(nt => `'${nt}'`).join(", ");
|
||||
|
||||
|
||||
this.findAndFixIssues(`
|
||||
SELECT noteId, type
|
||||
FROM notes
|
||||
@@ -696,14 +696,14 @@ class ConsistencyChecks {
|
||||
|
||||
function getBlankContent(isProtected, type, mime) {
|
||||
if (isProtected) {
|
||||
return null; // this is wrong for protected non-erased notes but we cannot create a valid value without password
|
||||
return null; // this is wrong for protected non-erased notes, but we cannot create a valid value without password
|
||||
}
|
||||
|
||||
if (mime === 'application/json') {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
return ''; // empty string might be wrong choice for some note types but it's a best guess
|
||||
return ''; // empty string might be wrong choice for some note types, but it's the best guess
|
||||
}
|
||||
|
||||
function logFix(message) {
|
||||
|
||||
@@ -19,7 +19,7 @@ function createToken(tokenName) {
|
||||
name: tokenName,
|
||||
tokenHash
|
||||
}).save();
|
||||
|
||||
|
||||
return {
|
||||
authToken: `${etapiToken.etapiTokenId}_${token}`
|
||||
};
|
||||
@@ -29,14 +29,14 @@ function parseAuthToken(auth) {
|
||||
if (!auth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const chunks = auth.split("_");
|
||||
|
||||
|
||||
if (chunks.length === 1) {
|
||||
return { token: auth }; // legacy format without etapiTokenId
|
||||
}
|
||||
else if (chunks.length === 2) {
|
||||
return {
|
||||
return {
|
||||
etapiTokenId: chunks[0],
|
||||
token: chunks[1]
|
||||
}
|
||||
@@ -48,20 +48,20 @@ function parseAuthToken(auth) {
|
||||
|
||||
function isValidAuthHeader(auth) {
|
||||
const parsed = parseAuthToken(auth);
|
||||
|
||||
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authTokenHash = getTokenHash(parsed.token);
|
||||
|
||||
|
||||
const authTokenHash = getTokenHash(parsed.token);
|
||||
|
||||
if (parsed.etapiTokenId) {
|
||||
const etapiToken = becca.getEtapiToken(parsed.etapiTokenId);
|
||||
|
||||
|
||||
if (!etapiToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return etapiToken.tokenHash === authTokenHash;
|
||||
}
|
||||
else {
|
||||
@@ -70,31 +70,30 @@ function isValidAuthHeader(auth) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renameToken(etapiTokenId, newName) {
|
||||
const etapiToken = becca.getEtapiToken(etapiTokenId);
|
||||
|
||||
|
||||
if (!etapiToken) {
|
||||
throw new Error(`Token ${etapiTokenId} does not exist`);
|
||||
}
|
||||
|
||||
|
||||
etapiToken.name = newName;
|
||||
etapiToken.save();
|
||||
}
|
||||
|
||||
function deleteToken(etapiTokenId) {
|
||||
const etapiToken = becca.getEtapiToken(etapiTokenId);
|
||||
|
||||
|
||||
if (!etapiToken) {
|
||||
return; // ok, already deleted
|
||||
}
|
||||
|
||||
etapiToken.isDeleted = true;
|
||||
etapiToken.save();
|
||||
|
||||
etapiToken.markAsDeletedSimple();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -104,4 +103,4 @@ module.exports = {
|
||||
deleteToken,
|
||||
parseAuthToken,
|
||||
isValidAuthHeader
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const fs = require("fs");
|
||||
const becca = require("../../becca/becca");
|
||||
const RESOURCE_DIR = require('../../services/resource_dir').RESOURCE_DIR;
|
||||
const archiver = require('archiver');
|
||||
const log = require("../log");
|
||||
|
||||
/**
|
||||
* @param {TaskContext} taskContext
|
||||
@@ -54,7 +55,7 @@ function exportToZip(taskContext, branch, format, res) {
|
||||
|
||||
let existingExtension = path.extname(fileName).toLowerCase();
|
||||
let newExtension;
|
||||
|
||||
|
||||
if (fileName.length > 30) {
|
||||
fileName = fileName.substr(0, 30);
|
||||
}
|
||||
@@ -254,7 +255,9 @@ ${content}
|
||||
</html>`;
|
||||
}
|
||||
|
||||
return html.prettyPrint(content, {indent_size: 2});
|
||||
return content.length < 100000
|
||||
? html.prettyPrint(content, {indent_size: 2})
|
||||
: content;
|
||||
}
|
||||
else if (noteMeta.format === 'markdown') {
|
||||
let markdownContent = mdService.toMarkdown(content);
|
||||
@@ -274,6 +277,8 @@ ${content}
|
||||
const notePaths = {};
|
||||
|
||||
function saveNote(noteMeta, filePathPrefix) {
|
||||
log.info(`Exporting note ${noteMeta.noteId}`);
|
||||
|
||||
if (noteMeta.isClone) {
|
||||
const targetUrl = getTargetUrl(noteMeta.noteId, noteMeta);
|
||||
|
||||
|
||||
+16
-6
@@ -17,8 +17,7 @@ async function processImage(uploadBuffer, originalName, shrinkImageSwitch) {
|
||||
const compressImages = optionService.getOptionBool("compressImages");
|
||||
const origImageFormat = getImageType(uploadBuffer);
|
||||
|
||||
if (origImageFormat && ["webp", "svg", "gif"].includes(origImageFormat.ext)) {
|
||||
// JIMP does not support webp at the moment: https://github.com/oliver-moran/jimp/issues/144
|
||||
if (!origImageFormat || !["jpg", "png"].includes(origImageFormat.ext)) {
|
||||
shrinkImageSwitch = false;
|
||||
}
|
||||
else if (isAnimated(uploadBuffer)) {
|
||||
@@ -26,9 +25,18 @@ async function processImage(uploadBuffer, originalName, shrinkImageSwitch) {
|
||||
shrinkImageSwitch = false;
|
||||
}
|
||||
|
||||
const finalImageBuffer = (compressImages && shrinkImageSwitch) ? await shrinkImage(uploadBuffer, originalName) : uploadBuffer;
|
||||
let finalImageBuffer;
|
||||
let imageFormat;
|
||||
|
||||
const imageFormat = getImageType(finalImageBuffer);
|
||||
if (compressImages && shrinkImageSwitch) {
|
||||
finalImageBuffer = await shrinkImage(uploadBuffer, originalName);
|
||||
imageFormat = getImageType(finalImageBuffer);
|
||||
} else {
|
||||
finalImageBuffer = uploadBuffer;
|
||||
imageFormat = origImageFormat || {
|
||||
ext: 'dat'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: finalImageBuffer,
|
||||
@@ -43,7 +51,9 @@ function getImageType(buffer) {
|
||||
}
|
||||
}
|
||||
else {
|
||||
return imageType(buffer) || "jpg"; // optimistic JPG default
|
||||
return imageType(buffer) || {
|
||||
ext: "jpg"
|
||||
}; // optimistic JPG default
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +85,7 @@ function updateImage(noteId, uploadBuffer, originalName) {
|
||||
}
|
||||
|
||||
function saveImage(parentNoteId, uploadBuffer, originalName, shrinkImageSwitch, trimFilename = false) {
|
||||
log.info(`Saving image ${originalName}`);
|
||||
log.info(`Saving image ${originalName} into parent ${parentNoteId}`);
|
||||
|
||||
if (trimFilename && originalName.length > 40) {
|
||||
// https://github.com/zadam/trilium/issues/2307
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const dataDir = require('./data_dir');
|
||||
const cls = require('./cls');
|
||||
|
||||
if (!fs.existsSync(dataDir.LOG_DIR)) {
|
||||
fs.mkdirSync(dataDir.LOG_DIR, 0o700);
|
||||
@@ -49,6 +50,12 @@ function checkDate(millisSinceMidnight) {
|
||||
}
|
||||
|
||||
function log(str) {
|
||||
const bundleNoteId = cls.get("bundleNoteId");
|
||||
|
||||
if (bundleNoteId) {
|
||||
str = `[Script ${bundleNoteId}] ${str}`;
|
||||
}
|
||||
|
||||
let millisSinceMidnight = Date.now() - todaysMidnight.getTime();
|
||||
|
||||
millisSinceMidnight = checkDate(millisSinceMidnight);
|
||||
|
||||
+12
-2
@@ -138,6 +138,8 @@ function createNewNote(params) {
|
||||
triggerNoteTitleChanged(note);
|
||||
triggerChildNoteCreated(note, parentNote);
|
||||
|
||||
log.info(`Created new note ${note.noteId}, branch ${branch.branchId} of type ${note.type}, mime ${note.mime}`);
|
||||
|
||||
return {
|
||||
note,
|
||||
branch
|
||||
@@ -632,7 +634,10 @@ function undeleteBranch(branchId, deleteId, taskContext) {
|
||||
taskContext.increaseProgressCount();
|
||||
|
||||
if (note.isDeleted && note.deleteId === deleteId) {
|
||||
new Note(note).save();
|
||||
// becca entity was already created as skeleton in "new Branch()" above
|
||||
const noteEntity = becca.getNote(note.noteId);
|
||||
noteEntity.updateFromRow(note);
|
||||
noteEntity.save();
|
||||
|
||||
const attributes = sql.getRows(`
|
||||
SELECT * FROM attributes
|
||||
@@ -736,6 +741,8 @@ function eraseBranches(branchIdsToErase) {
|
||||
sql.executeMany(`DELETE FROM branches WHERE branchId IN (???)`, branchIdsToErase);
|
||||
|
||||
setEntityChangesAsErased(sql.getManyRows(`SELECT * FROM entity_changes WHERE entityName = 'branches' AND entityId IN (???)`, branchIdsToErase));
|
||||
|
||||
log.info(`Erased branches: ${JSON.stringify(branchIdsToErase)}`);
|
||||
}
|
||||
|
||||
function eraseAttributes(attributeIdsToErase) {
|
||||
@@ -746,6 +753,8 @@ function eraseAttributes(attributeIdsToErase) {
|
||||
sql.executeMany(`DELETE FROM attributes WHERE attributeId IN (???)`, attributeIdsToErase);
|
||||
|
||||
setEntityChangesAsErased(sql.getManyRows(`SELECT * FROM entity_changes WHERE entityName = 'attributes' AND entityId IN (???)`, attributeIdsToErase));
|
||||
|
||||
log.info(`Erased attributes: ${JSON.stringify(attributeIdsToErase)}`);
|
||||
}
|
||||
|
||||
function eraseDeletedEntities(eraseEntitiesAfterTimeInSeconds = null) {
|
||||
@@ -940,5 +949,6 @@ module.exports = {
|
||||
triggerNoteTitleChanged,
|
||||
eraseDeletedNotesNow,
|
||||
eraseNotesWithDeleteId,
|
||||
saveNoteRevision
|
||||
saveNoteRevision,
|
||||
downloadImages
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ function executeBundle(bundle, apiParams = {}) {
|
||||
const originalComponentId = cls.get('componentId');
|
||||
|
||||
cls.set('componentId', 'script');
|
||||
cls.set('bundleNoteId', bundle.note.noteId);
|
||||
|
||||
// last \r\n is necessary if script contains line comment on its last line
|
||||
const script = "function() {\r\n" + bundle.script + "\r\n}";
|
||||
|
||||
@@ -222,8 +222,6 @@ function getShareRoot() {
|
||||
function createMissingSpecialNotes() {
|
||||
getSinglesNoteRoot();
|
||||
getSqlConsoleRoot();
|
||||
getSinglesNoteRoot();
|
||||
getSinglesNoteRoot();
|
||||
getGlobalNoteMap();
|
||||
// share root is not automatically created since it's visible in the tree and many won't need it/use it
|
||||
|
||||
|
||||
@@ -151,6 +151,14 @@ function setDbAsInitialized() {
|
||||
}
|
||||
}
|
||||
|
||||
function optimize() {
|
||||
log.info("Optimizing database");
|
||||
|
||||
sql.execute("PRAGMA optimize");
|
||||
|
||||
log.info("Optimization finished.");
|
||||
}
|
||||
|
||||
dbReady.then(() => {
|
||||
if (config.General && config.General.noBackup === true) {
|
||||
log.info("Disabling scheduled backups.");
|
||||
@@ -162,6 +170,11 @@ dbReady.then(() => {
|
||||
|
||||
// kickoff first backup soon after start up
|
||||
setTimeout(() => require('./backup').regularBackup(), 5 * 60 * 1000);
|
||||
|
||||
// optimize is usually inexpensive no-op so running it semi-frequently is not a big deal
|
||||
setTimeout(() => optimize(), 60 * 60 * 1000);
|
||||
|
||||
setInterval(() => optimize(), 10 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
log.info("DB size: " + sql.getValue("SELECT page_count * page_size / 1000 as size FROM pragma_page_count(), pragma_page_size()") + " KB");
|
||||
|
||||
@@ -131,7 +131,8 @@ async function pullChanges(syncContext) {
|
||||
|
||||
while (true) {
|
||||
const lastSyncedPull = getLastSyncedPull();
|
||||
const changesUri = `/api/sync/changed?instanceId=${instanceId}&lastEntityChangeId=${lastSyncedPull}`;
|
||||
const logMarkerId = utils.randomString(10); // to easily pair sync events between client and server logs
|
||||
const changesUri = `/api/sync/changed?instanceId=${instanceId}&lastEntityChangeId=${lastSyncedPull}&logMarkerId=${logMarkerId}`;
|
||||
|
||||
const startDate = Date.now();
|
||||
|
||||
@@ -168,7 +169,7 @@ async function pullChanges(syncContext) {
|
||||
} else {
|
||||
const sizeInKb = Math.round(JSON.stringify(resp).length / 1024);
|
||||
|
||||
log.info(`Pulled ${entityChanges.length} changes in ${sizeInKb} KB, starting at entityChangeId=${lastSyncedPull} in ${pulledDate - startDate}ms and applied them in ${Date.now() - pulledDate}ms, ${outstandingPullCount} outstanding pulls`);
|
||||
log.info(`Sync ${logMarkerId}: Pulled ${entityChanges.length} changes in ${sizeInKb} KB, starting at entityChangeId=${lastSyncedPull} in ${pulledDate - startDate}ms and applied them in ${Date.now() - pulledDate}ms, ${outstandingPullCount} outstanding pulls`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,14 +212,16 @@ async function pushChanges(syncContext) {
|
||||
const entityChangesRecords = getEntityChangeRecords(filteredEntityChanges);
|
||||
const startDate = new Date();
|
||||
|
||||
await syncRequest(syncContext, 'PUT', '/api/sync/update', {
|
||||
const logMarkerId = utils.randomString(10); // to easily pair sync events between client and server logs
|
||||
|
||||
await syncRequest(syncContext, 'PUT', `/api/sync/update?logMarkerId=${logMarkerId}`, {
|
||||
entities: entityChangesRecords,
|
||||
instanceId
|
||||
});
|
||||
|
||||
ws.syncPushInProgress();
|
||||
|
||||
log.info(`Pushing ${entityChangesRecords.length} sync changes in ` + (Date.now() - startDate.getTime()) + "ms");
|
||||
log.info(`Sync ${logMarkerId}: Pushing ${entityChangesRecords.length} sync changes in ` + (Date.now() - startDate.getTime()) + "ms");
|
||||
|
||||
lastSyncedPush = entityChangesRecords[entityChangesRecords.length - 1].entityChange.id;
|
||||
|
||||
|
||||
@@ -223,12 +223,15 @@ function setNoteToParent(noteId, prefix, parentNoteId) {
|
||||
|
||||
if (branch) {
|
||||
if (!parentNoteId) {
|
||||
log.info(`Removing note ${noteId} from parent ${parentNoteId}`);
|
||||
|
||||
branch.markAsDeleted();
|
||||
}
|
||||
else {
|
||||
branch.parentNoteId = parentNoteId;
|
||||
branch.prefix = prefix;
|
||||
branch.save();
|
||||
const newBranch = branch.createClone(parentNoteId);
|
||||
newBranch.save();
|
||||
|
||||
branch.markAsDeleted();
|
||||
}
|
||||
}
|
||||
else if (parentNoteId) {
|
||||
|
||||
+37
-13
@@ -20,23 +20,39 @@ function getSharedSubTreeRoot(note) {
|
||||
return getSharedSubTreeRoot(parentNote);
|
||||
}
|
||||
|
||||
function addNoIndexHeader(note, res) {
|
||||
if (note.hasLabel('shareDisallowRobotIndexing')) {
|
||||
res.setHeader('X-Robots-Tag', 'noindex');
|
||||
}
|
||||
}
|
||||
|
||||
function register(router) {
|
||||
function renderNote(note, res) {
|
||||
if (note) {
|
||||
const {header, content, isEmpty} = contentRenderer.getContent(note);
|
||||
|
||||
const subRoot = getSharedSubTreeRoot(note);
|
||||
|
||||
res.render("share/page", {
|
||||
note,
|
||||
header,
|
||||
content,
|
||||
isEmpty,
|
||||
subRoot
|
||||
});
|
||||
} else {
|
||||
if (!note) {
|
||||
res.status(404).render("share/404");
|
||||
return;
|
||||
}
|
||||
|
||||
addNoIndexHeader(note, res);
|
||||
|
||||
if (note.hasLabel('shareRaw') || ['image', 'file'].includes(note.type)) {
|
||||
res.setHeader('Content-Type', note.mime);
|
||||
|
||||
res.send(note.getContent());
|
||||
return;
|
||||
}
|
||||
|
||||
const {header, content, isEmpty} = contentRenderer.getContent(note);
|
||||
|
||||
const subRoot = getSharedSubTreeRoot(note);
|
||||
|
||||
res.render("share/page", {
|
||||
note,
|
||||
header,
|
||||
content,
|
||||
isEmpty,
|
||||
subRoot
|
||||
});
|
||||
}
|
||||
|
||||
router.get(['/share', '/share/'], (req, res, next) => {
|
||||
@@ -63,6 +79,8 @@ function register(router) {
|
||||
return res.status(404).send(`Note ${noteId} not found`);
|
||||
}
|
||||
|
||||
addNoIndexHeader(note, res);
|
||||
|
||||
res.json(note.getPojoWithAttributes());
|
||||
});
|
||||
|
||||
@@ -74,6 +92,8 @@ function register(router) {
|
||||
return res.status(404).send(`Note ${noteId} not found`);
|
||||
}
|
||||
|
||||
addNoIndexHeader(note, res);
|
||||
|
||||
const utils = require("../services/utils");
|
||||
|
||||
const filename = utils.formatDownloadTitle(note.title, note.type, note.mime);
|
||||
@@ -96,6 +116,8 @@ function register(router) {
|
||||
return res.status(400).send("Requested note is not an image");
|
||||
}
|
||||
|
||||
addNoIndexHeader(image, res);
|
||||
|
||||
res.set('Content-Type', image.mime);
|
||||
|
||||
res.send(image.getContent());
|
||||
@@ -110,6 +132,8 @@ function register(router) {
|
||||
return res.status(404).send(`Note ${noteId} not found`);
|
||||
}
|
||||
|
||||
addNoIndexHeader(note, res);
|
||||
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader('Content-Type', note.mime);
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ class Attribute extends AbstractEntity {
|
||||
const linkedChildNote = this.note.getChildNotes().find(childNote => childNote.noteId === this.value);
|
||||
|
||||
if (linkedChildNote) {
|
||||
this.note.children = this.note.children.filter(childNote => childNote.noteId !== this.value);
|
||||
const branch = this.shaca.getBranchFromChildAndParent(this.noteId, linkedChildNote.noteId);
|
||||
|
||||
linkedChildNote.parents = linkedChildNote.parents.filter(parentNote => parentNote.noteId !== this.noteId);
|
||||
branch.isHidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ class Branch extends AbstractEntity {
|
||||
this.prefix = prefix;
|
||||
/** @param {boolean} */
|
||||
this.isExpanded = !!isExpanded;
|
||||
/** @param {boolean} */
|
||||
this.isHidden = false;
|
||||
|
||||
const childNote = this.childNote;
|
||||
const parentNote = this.parentNote;
|
||||
|
||||
@@ -58,10 +58,21 @@ class Note extends AbstractEntity {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
getVisibleChildNotes() {
|
||||
return this.getChildBranches()
|
||||
.filter(branch => !branch.isHidden)
|
||||
.map(branch => branch.getNote())
|
||||
.filter(childNote => !childNote.hasLabel('shareHiddenFromTree') && !childNote.isProtected);
|
||||
}
|
||||
|
||||
hasChildren() {
|
||||
return this.children && this.children.length > 0;
|
||||
}
|
||||
|
||||
hasVisibleChildren() {
|
||||
return this.getVisibleChildNotes().length > 0;
|
||||
}
|
||||
|
||||
getChildBranches() {
|
||||
return this.children.map(childNote => this.shaca.getBranchFromChildAndParent(childNote.noteId, this.noteId));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div id="prompt-dialog" class="modal mx-auto" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<form id="prompt-dialog-form">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<% for (const jsRelation of note.getRelations("shareJs")) { %>
|
||||
<script type="module" src="api/notes/<%= jsRelation.value %>/download"></script>
|
||||
<% } %>
|
||||
<% if (note.hasLabel('shareDisallowRobotIndexing')) { %>
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
<% } %>
|
||||
<%- header %>
|
||||
<title><%= note.title %></title>
|
||||
</head>
|
||||
@@ -46,7 +49,7 @@
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (note.hasChildren()) { %>
|
||||
<% if (note.hasVisibleChildren()) { %>
|
||||
<nav id="childLinks" class="<% if (isEmpty) { %>grid<% } else { %>list<% } %>">
|
||||
<% if (!isEmpty) { %>
|
||||
<hr>
|
||||
@@ -54,7 +57,7 @@
|
||||
<% } %>
|
||||
|
||||
<ul>
|
||||
<% for (const childNote of note.getChildNotes()) { %>
|
||||
<% for (const childNote of note.getVisibleChildNotes()) { %>
|
||||
<li>
|
||||
<a href="<%= childNote.shareId %>"
|
||||
class="type-<%= childNote.type %>"><%= childNote.title %></a>
|
||||
@@ -67,7 +70,7 @@
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (subRoot.hasChildren()) { %>
|
||||
<% if (subRoot.hasVisibleChildren()) { %>
|
||||
<button id="toggleMenuButton"></button>
|
||||
|
||||
<nav id="menu">
|
||||
|
||||
@@ -8,12 +8,10 @@
|
||||
|
||||
<% if (note.hasChildren()) { %>
|
||||
<ul>
|
||||
<% note.getChildNotes().forEach(function (childNote) { %>
|
||||
<% if (!childNote.hasLabel("shareHiddenFromTree")) { %>
|
||||
<% note.getVisibleChildNotes().forEach(function (childNote) { %>
|
||||
<li>
|
||||
<%- include('tree_item', {note: childNote}) %>
|
||||
</li>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<% } %>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
GET {{triliumHost}}/etapi/app-info
|
||||
Authorization: {{authToken}}
|
||||
|
||||
> {%
|
||||
client.assert(response.status === 200);
|
||||
client.assert(response.body == "Hi there!");
|
||||
%}
|
||||
@@ -96,8 +96,14 @@ POST {{triliumHost}}/etapi/create-note
|
||||
|
||||
> {% client.assert(response.status === 401); %}
|
||||
|
||||
###
|
||||
|
||||
GET {{triliumHost}}/etapi/app-info
|
||||
|
||||
> {% client.assert(response.status === 401); %}
|
||||
|
||||
### Fake URL will get a 404 even without token
|
||||
|
||||
GET {{triliumHost}}/etapi/zzzzzz
|
||||
|
||||
> {% client.assert(response.status === 404); %}
|
||||
> {% client.assert(response.status === 404); %}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
POST {{triliumHost}}/etapi/create-note
|
||||
Authorization: {{authToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"parentNoteId": "root",
|
||||
"title": "Hello",
|
||||
"type": "image",
|
||||
"mime": "image/png",
|
||||
"content": ""
|
||||
}
|
||||
|
||||
> {% client.global.set("createdNoteId", response.body.note.noteId); %}
|
||||
|
||||
###
|
||||
|
||||
PUT {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
|
||||
Authorization: {{authToken}}
|
||||
Content-Type: application/octet-stream
|
||||
Content-Transfer-Encoding: binary
|
||||
|
||||
< ../images/icon-color.png
|
||||
|
||||
> {% client.assert(response.status === 204); %}
|
||||
|
||||
@@ -20,6 +20,8 @@ Content-Type: text/plain
|
||||
|
||||
Changed content
|
||||
|
||||
> {% client.assert(response.status === 200); %}
|
||||
|
||||
###
|
||||
|
||||
GET {{triliumHost}}/etapi/notes/{{createdNoteId}}/content
|
||||
|
||||
Reference in New Issue
Block a user