Merge pull request #273 from TriliumNext/feature/client_typescript_port1

Port frontend to TypeScript (0% -> 36.7%)
pull/835/head
Elian Doran 2024-12-22 15:17:00 +07:00 committed by GitHub
commit b920fb24ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1280 additions and 774 deletions

1
.gitattributes vendored

@ -0,0 +1 @@
**/package-lock.json linguist-generated=true

@ -1,10 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
cd src/public
echo Summary
cloc HEAD \ cloc HEAD \
--git --md \ --git --md \
--include-lang=javascript,typescript \ --include-lang=javascript,typescript
--found=filelist.txt \
--exclude-dir=public,libraries,views,docs
grep -R \.js$ filelist.txt echo By file
rm filelist.txt cloc HEAD \
--git --md \
--include-lang=javascript,typescript \
--by-file

108
package-lock.json generated

@ -93,6 +93,7 @@
"striptags": "3.2.0", "striptags": "3.2.0",
"tmp": "0.2.3", "tmp": "0.2.3",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"ts-loader": "9.5.1",
"turndown": "7.2.0", "turndown": "7.2.0",
"unescape": "1.0.1", "unescape": "1.0.1",
"vanilla-js-wheel-zoom": "9.0.4", "vanilla-js-wheel-zoom": "9.0.4",
@ -113,6 +114,7 @@
"@playwright/test": "1.49.1", "@playwright/test": "1.49.1",
"@types/archiver": "6.0.3", "@types/archiver": "6.0.3",
"@types/better-sqlite3": "7.6.12", "@types/better-sqlite3": "7.6.12",
"@types/bootstrap": "5.2.10",
"@types/cheerio": "0.22.35", "@types/cheerio": "0.22.35",
"@types/cls-hooked": "4.3.9", "@types/cls-hooked": "4.3.9",
"@types/compression": "1.7.5", "@types/compression": "1.7.5",
@ -124,9 +126,11 @@
"@types/escape-html": "1.0.4", "@types/escape-html": "1.0.4",
"@types/express": "5.0.0", "@types/express": "5.0.0",
"@types/express-session": "1.18.1", "@types/express-session": "1.18.1",
"@types/fs-extra": "11.0.4",
"@types/html": "1.0.4", "@types/html": "1.0.4",
"@types/ini": "4.1.1", "@types/ini": "4.1.1",
"@types/jasmine": "5.1.5", "@types/jasmine": "5.1.5",
"@types/jquery": "3.5.32",
"@types/jsdom": "21.1.7", "@types/jsdom": "21.1.7",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/multer": "1.4.12", "@types/multer": "1.4.12",
@ -3611,6 +3615,17 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@shikijs/engine-oniguruma": { "node_modules/@shikijs/engine-oniguruma": {
"version": "1.24.2", "version": "1.24.2",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.24.2.tgz", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.24.2.tgz",
@ -3767,6 +3782,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/bootstrap": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz",
"integrity": "sha512-F2X+cd6551tep0MvVZ6nM8v7XgGN/twpdNDjqS1TUM7YFNEtQYWk+dKAnH+T1gr6QgCoGMPl487xw/9hXooa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.2"
}
},
"node_modules/@types/cacheable-request": { "node_modules/@types/cacheable-request": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@ -4186,13 +4211,13 @@
} }
}, },
"node_modules/@types/fs-extra": { "node_modules/@types/fs-extra": {
"version": "9.0.13", "version": "11.0.4",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
"integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"@types/jsonfile": "*",
"@types/node": "*" "@types/node": "*"
} }
}, },
@ -4259,6 +4284,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jquery": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.32.tgz",
"integrity": "sha512-b9Xbf4CkMqS02YH8zACqN1xzdxc3cO735Qe5AbSUFmyOiaWAbcpqh9Wna+Uk0vgACvoQHpWDg2rGdHkYPLmCiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/sizzle": "*"
}
},
"node_modules/@types/jsdom": { "node_modules/@types/jsdom": {
"version": "21.1.7", "version": "21.1.7",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz",
@ -4277,6 +4312,16 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonfile": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz",
"integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/keyv": { "node_modules/@types/keyv": {
"version": "3.1.4", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
@ -4466,6 +4511,13 @@
"@types/express-session": "*" "@types/express-session": "*"
} }
}, },
"node_modules/@types/sizzle": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.9.tgz",
"integrity": "sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/source-map-support": { "node_modules/@types/source-map-support": {
"version": "0.5.10", "version": "0.5.10",
"resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.10.tgz", "resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.10.tgz",
@ -5771,7 +5823,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@ -8142,6 +8193,17 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/electron-installer-common/node_modules/@types/fs-extra": {
"version": "9.0.13",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
"integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/electron-installer-common/node_modules/fs-extra": { "node_modules/electron-installer-common/node_modules/fs-extra": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -8974,7 +9036,6 @@
"version": "5.17.1", "version": "5.17.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz",
"integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.4", "graceful-fs": "^4.2.4",
@ -9957,7 +10018,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@ -11803,7 +11863,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@ -13187,7 +13246,6 @@
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
@ -14541,7 +14599,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@ -17116,7 +17173,6 @@
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -17506,7 +17562,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
@ -17642,6 +17697,35 @@
"node": ">=6.10" "node": ">=6.10"
} }
}, },
"node_modules/ts-loader": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.1.tgz",
"integrity": "sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==",
"license": "MIT",
"dependencies": {
"chalk": "^4.1.0",
"enhanced-resolve": "^5.0.0",
"micromatch": "^4.0.0",
"semver": "^7.3.4",
"source-map": "^0.7.4"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"typescript": "*",
"webpack": "^5.0.0"
}
},
"node_modules/ts-loader/node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
"integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 8"
}
},
"node_modules/ts-node": { "node_modules/ts-node": {
"version": "10.9.2", "version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",

@ -136,6 +136,7 @@
"striptags": "3.2.0", "striptags": "3.2.0",
"tmp": "0.2.3", "tmp": "0.2.3",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"ts-loader": "9.5.1",
"turndown": "7.2.0", "turndown": "7.2.0",
"unescape": "1.0.1", "unescape": "1.0.1",
"vanilla-js-wheel-zoom": "9.0.4", "vanilla-js-wheel-zoom": "9.0.4",
@ -153,6 +154,7 @@
"@playwright/test": "1.49.1", "@playwright/test": "1.49.1",
"@types/archiver": "6.0.3", "@types/archiver": "6.0.3",
"@types/better-sqlite3": "7.6.12", "@types/better-sqlite3": "7.6.12",
"@types/bootstrap": "5.2.10",
"@types/cheerio": "0.22.35", "@types/cheerio": "0.22.35",
"@types/cls-hooked": "4.3.9", "@types/cls-hooked": "4.3.9",
"@types/compression": "1.7.5", "@types/compression": "1.7.5",
@ -164,9 +166,11 @@
"@types/escape-html": "1.0.4", "@types/escape-html": "1.0.4",
"@types/express": "5.0.0", "@types/express": "5.0.0",
"@types/express-session": "1.18.1", "@types/express-session": "1.18.1",
"@types/fs-extra": "11.0.4",
"@types/html": "1.0.4", "@types/html": "1.0.4",
"@types/ini": "4.1.1", "@types/ini": "4.1.1",
"@types/jasmine": "5.1.5", "@types/jasmine": "5.1.5",
"@types/jquery": "3.5.32",
"@types/jsdom": "21.1.7", "@types/jsdom": "21.1.7",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/multer": "1.4.12", "@types/multer": "1.4.12",

@ -15,8 +15,34 @@ import toast from "../services/toast.js";
import ShortcutComponent from "./shortcut_component.js"; import ShortcutComponent from "./shortcut_component.js";
import { t, initLocale } from "../services/i18n.js"; import { t, initLocale } from "../services/i18n.js";
interface Layout {
getRootWidget: (appContext: AppContext) => RootWidget;
}
interface RootWidget extends Component {
render: () => JQuery<HTMLElement>;
}
interface BeforeUploadListener extends Component {
beforeUnloadEvent(): boolean;
}
interface TriggerData {
noteId?: string;
noteIds?: string[];
messages?: unknown[];
callback?: () => void;
}
class AppContext extends Component { class AppContext extends Component {
constructor(isMainWindow) {
isMainWindow: boolean;
components: Component[];
beforeUnloadListeners: WeakRef<BeforeUploadListener>[];
tabManager!: TabManager;
layout?: Layout;
constructor(isMainWindow: boolean) {
super(); super();
this.isMainWindow = isMainWindow; this.isMainWindow = isMainWindow;
@ -33,7 +59,7 @@ class AppContext extends Component {
await initLocale(); await initLocale();
} }
setLayout(layout) { setLayout(layout: Layout) {
this.layout = layout; this.layout = layout;
} }
@ -73,6 +99,10 @@ class AppContext extends Component {
} }
renderWidgets() { renderWidgets() {
if (!this.layout) {
throw new Error("Missing layout.");
}
const rootWidget = this.layout.getRootWidget(this); const rootWidget = this.layout.getRootWidget(this);
const $renderedWidget = rootWidget.render(); const $renderedWidget = rootWidget.render();
@ -97,15 +127,13 @@ class AppContext extends Component {
this.triggerEvent('initialRenderComplete'); this.triggerEvent('initialRenderComplete');
} }
/** @returns {Promise<void>} */ triggerEvent(name: string, data: TriggerData = {}) {
triggerEvent(name, data = {}) {
return this.handleEvent(name, data); return this.handleEvent(name, data);
} }
/** @returns {Promise<*>} */ triggerCommand(name: string, data: TriggerData = {}) {
triggerCommand(name, data = {}) {
for (const executor of this.components) { for (const executor of this.components) {
const fun = executor[`${name}Command`]; const fun = (executor as any)[`${name}Command`];
if (fun) { if (fun) {
return executor.callMethod(fun, data); return executor.callMethod(fun, data);
@ -119,17 +147,17 @@ class AppContext extends Component {
return this.triggerEvent(name, data); return this.triggerEvent(name, data);
} }
getComponentByEl(el) { getComponentByEl(el: HTMLElement) {
return $(el).closest(".component").prop('component'); return $(el).closest(".component").prop('component');
} }
addBeforeUnloadListener(obj) { addBeforeUnloadListener(obj: BeforeUploadListener) {
if (typeof WeakRef !== "function") { if (typeof WeakRef !== "function") {
// older browsers don't support WeakRef // older browsers don't support WeakRef
return; return;
} }
this.beforeUnloadListeners.push(new WeakRef(obj)); this.beforeUnloadListeners.push(new WeakRef<BeforeUploadListener>(obj));
} }
} }

@ -12,9 +12,13 @@ import utils from '../services/utils.js';
* event / command is executed in all components - by simply awaiting the `triggerEvent()`. * event / command is executed in all components - by simply awaiting the `triggerEvent()`.
*/ */
export default class Component { export default class Component {
componentId: string;
children: Component[];
initialized: Promise<void> | null;
parent?: Component;
constructor() { constructor() {
this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`; this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`;
/** @type Component[] */
this.children = []; this.children = [];
this.initialized = null; this.initialized = null;
} }
@ -24,13 +28,12 @@ export default class Component {
return this.constructor.name.replace(/[^A-Z0-9]/ig, "_"); return this.constructor.name.replace(/[^A-Z0-9]/ig, "_");
} }
setParent(parent) { setParent(parent: Component) {
/** @type Component */
this.parent = parent; this.parent = parent;
return this; return this;
} }
child(...components) { child(...components: Component[]) {
for (const component of components) { for (const component of components) {
component.setParent(this); component.setParent(this);
@ -40,12 +43,11 @@ export default class Component {
return this; return this;
} }
/** @returns {Promise<void>} */ handleEvent(name: string, data: unknown): Promise<unknown> | null {
handleEvent(name, data) {
try { try {
const callMethodPromise = this.initialized const callMethodPromise = this.initialized
? this.initialized.then(() => this.callMethod(this[`${name}Event`], data)) ? this.initialized.then(() => this.callMethod((this as any)[`${name}Event`], data))
: this.callMethod(this[`${name}Event`], data); : this.callMethod((this as any)[`${name}Event`], data);
const childrenPromise = this.handleEventInChildren(name, data); const childrenPromise = this.handleEventInChildren(name, data);
@ -54,20 +56,18 @@ export default class Component {
? Promise.all([callMethodPromise, childrenPromise]) ? Promise.all([callMethodPromise, childrenPromise])
: (callMethodPromise || childrenPromise); : (callMethodPromise || childrenPromise);
} }
catch (e) { catch (e: any) {
console.error(`Handling of event '${name}' failed in ${this.constructor.name} with error ${e.message} ${e.stack}`); console.error(`Handling of event '${name}' failed in ${this.constructor.name} with error ${e.message} ${e.stack}`);
return null; return null;
} }
} }
/** @returns {Promise<void>} */ triggerEvent(name: string, data = {}): Promise<unknown> | undefined | null {
triggerEvent(name, data = {}) { return this.parent?.triggerEvent(name, data);
return this.parent.triggerEvent(name, data);
} }
/** @returns {Promise<void>} */ handleEventInChildren(name: string, data: unknown = {}) {
handleEventInChildren(name, data = {}) {
const promises = []; const promises = [];
for (const child of this.children) { for (const child of this.children) {
@ -82,9 +82,8 @@ export default class Component {
return promises.length > 0 ? Promise.all(promises) : null; return promises.length > 0 ? Promise.all(promises) : null;
} }
/** @returns {Promise<*>} */ triggerCommand(name: string, data = {}): Promise<unknown> | undefined | null {
triggerCommand(name, data = {}) { const fun = (this as any)[`${name}Command`];
const fun = this[`${name}Command`];
if (fun) { if (fun) {
return this.callMethod(fun, data); return this.callMethod(fun, data);
@ -97,7 +96,7 @@ export default class Component {
} }
} }
callMethod(fun, data) { callMethod(fun: (arg: unknown) => Promise<unknown>, data: unknown) {
if (typeof fun !== 'function') { if (typeof fun !== 'function') {
return; return;
} }

@ -11,7 +11,10 @@ class ZoomComponent extends Component {
if (utils.isElectron()) { if (utils.isElectron()) {
options.initializedPromise.then(() => { options.initializedPromise.then(() => {
this.setZoomFactor(options.getFloat('zoomFactor')); const zoomFactor = options.getFloat('zoomFactor');
if (zoomFactor) {
this.setZoomFactor(zoomFactor);
}
}); });
window.addEventListener("wheel", event => { window.addEventListener("wheel", event => {
@ -22,14 +25,13 @@ class ZoomComponent extends Component {
} }
} }
setZoomFactor(zoomFactor) { setZoomFactor(zoomFactor: string | number) {
zoomFactor = parseFloat(zoomFactor); const parsedZoomFactor = (typeof zoomFactor !== "number" ? parseFloat(zoomFactor) : zoomFactor);
const webFrame = utils.dynamicRequire('electron').webFrame; const webFrame = utils.dynamicRequire('electron').webFrame;
webFrame.setZoomFactor(zoomFactor); webFrame.setZoomFactor(parsedZoomFactor);
} }
async setZoomFactorAndSave(zoomFactor) { async setZoomFactorAndSave(zoomFactor: number) {
if (zoomFactor >= MIN_ZOOM && zoomFactor <= MAX_ZOOM) { if (zoomFactor >= MIN_ZOOM && zoomFactor <= MAX_ZOOM) {
zoomFactor = Math.round(zoomFactor * 10) / 10; zoomFactor = Math.round(zoomFactor * 10) / 10;
@ -57,7 +59,7 @@ class ZoomComponent extends Component {
this.setZoomFactorAndSave(1); this.setZoomFactorAndSave(1);
} }
setZoomFactorAndSaveEvent({zoomFactor}) { setZoomFactorAndSaveEvent({ zoomFactor }: { zoomFactor: number }) {
this.setZoomFactorAndSave(zoomFactor); this.setZoomFactorAndSave(zoomFactor);
} }
} }

@ -1,48 +1,61 @@
import { Froca } from "../services/froca-interface.js";
export interface FAttachmentRow {
attachmentId: string;
ownerId: string;
role: string;
mime: string;
title: string;
dateModified: string;
utcDateModified: string;
utcDateScheduledForErasureSince: string;
contentLength: number;
}
/** /**
* Attachment is a file directly tied into a note without * Attachment is a file directly tied into a note without
* being a hidden child. * being a hidden child.
*/ */
class FAttachment { class FAttachment {
constructor(froca, row) { private froca: Froca;
attachmentId!: string;
private ownerId!: string;
role!: string;
private mime!: string;
private title!: string;
private dateModified!: string;
private utcDateModified!: string;
private utcDateScheduledForErasureSince!: string;
/**
* optionally added to the entity
*/
private contentLength!: number;
constructor(froca: Froca, row: FAttachmentRow) {
/** @type {Froca} */ /** @type {Froca} */
this.froca = froca; this.froca = froca;
this.update(row); this.update(row);
} }
update(row) { update(row: FAttachmentRow) {
/** @type {string} */
this.attachmentId = row.attachmentId; this.attachmentId = row.attachmentId;
/** @type {string} */
this.ownerId = row.ownerId; this.ownerId = row.ownerId;
/** @type {string} */
this.role = row.role; this.role = row.role;
/** @type {string} */
this.mime = row.mime; this.mime = row.mime;
/** @type {string} */
this.title = row.title; this.title = row.title;
/** @type {string} */
this.dateModified = row.dateModified; this.dateModified = row.dateModified;
/** @type {string} */
this.utcDateModified = row.utcDateModified; this.utcDateModified = row.utcDateModified;
/** @type {string} */
this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince; this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
/**
* optionally added to the entity
* @type {int}
*/
this.contentLength = row.contentLength; this.contentLength = row.contentLength;
this.froca.attachments[this.attachmentId] = this; this.froca.attachments[this.attachmentId] = this;
} }
/** @returns {FNote} */
getNote() { getNote() {
return this.froca.notes[this.ownerId]; return this.froca.notes[this.ownerId];
} }
/** @return {FBlob} */
async getBlob() { async getBlob() {
return await this.froca.getBlob('attachments', this.attachmentId); return await this.froca.getBlob('attachments', this.attachmentId);
} }

@ -1,45 +1,56 @@
import { Froca } from '../services/froca-interface.js';
import promotedAttributeDefinitionParser from '../services/promoted_attribute_definition_parser.js'; import promotedAttributeDefinitionParser from '../services/promoted_attribute_definition_parser.js';
/** /**
* There are currently only two types of attributes, labels or relations. * There are currently only two types of attributes, labels or relations.
* @typedef {"label" | "relation"} AttributeType
*/ */
export type AttributeType = "label" | "relation";
export interface FAttributeRow {
attributeId: string;
noteId: string;
type: AttributeType;
name: string;
value: string;
position: number;
isInheritable: boolean;
}
/** /**
* Attribute is an abstract concept which has two real uses - label (key - value pair) * Attribute is an abstract concept which has two real uses - label (key - value pair)
* and relation (representing named relationship between source and target note) * and relation (representing named relationship between source and target note)
*/ */
class FAttribute { class FAttribute {
constructor(froca, row) { private froca: Froca;
/** @type {Froca} */ attributeId!: string;
noteId!: string;
type!: AttributeType;
name!: string;
value!: string;
position!: number;
isInheritable!: boolean;
constructor(froca: Froca, row: FAttributeRow) {
this.froca = froca; this.froca = froca;
this.update(row); this.update(row);
} }
update(row) { update(row: FAttributeRow) {
/** @type {string} */
this.attributeId = row.attributeId; this.attributeId = row.attributeId;
/** @type {string} */
this.noteId = row.noteId; this.noteId = row.noteId;
/** @type {AttributeType} */
this.type = row.type; this.type = row.type;
/** @type {string} */
this.name = row.name; this.name = row.name;
/** @type {string} */
this.value = row.value; this.value = row.value;
/** @type {int} */
this.position = row.position; this.position = row.position;
/** @type {boolean} */
this.isInheritable = !!row.isInheritable; this.isInheritable = !!row.isInheritable;
} }
/** @returns {FNote} */
getNote() { getNote() {
return this.froca.notes[this.noteId]; return this.froca.notes[this.noteId];
} }
/** @returns {Promise<FNote>} */
async getTargetNote() { async getTargetNote() {
const targetNoteId = this.targetNoteId; const targetNoteId = this.targetNoteId;
@ -70,12 +81,12 @@ class FAttribute {
return promotedAttributeDefinitionParser.parse(this.value); return promotedAttributeDefinitionParser.parse(this.value);
} }
isDefinitionFor(attr) { isDefinitionFor(attr: FAttribute) {
return this.type === 'label' && this.name === `${attr.type}:${attr.name}`; return this.type === 'label' && this.name === `${attr.type}:${attr.name}`;
} }
get dto() { get dto(): Omit<FAttribute, "froca"> {
const dto = Object.assign({}, this); const dto: any = Object.assign({}, this);
delete dto.froca; delete dto.froca;
return dto; return dto;

@ -1,39 +0,0 @@
export default class FBlob {
constructor(row) {
/** @type {string} */
this.blobId = row.blobId;
/**
* can either contain the whole content (in e.g. string notes), only part (large text notes) or nothing at all (binary notes, images)
* @type {string}
*/
this.content = row.content;
this.contentLength = row.contentLength;
/** @type {string} */
this.dateModified = row.dateModified;
/** @type {string} */
this.utcDateModified = row.utcDateModified;
}
/**
* @returns {*}
* @throws Error in case of invalid JSON */
getJsonContent() {
if (!this.content || !this.content.trim()) {
return null;
}
return JSON.parse(this.content);
}
/** @returns {*|null} valid object or null if the content cannot be parsed as JSON */
getJsonContentSafely() {
try {
return this.getJsonContent();
}
catch (e) {
return null;
}
}
}

@ -0,0 +1,48 @@
export interface FBlobRow {
blobId: string;
content: string;
contentLength: number;
dateModified: string;
utcDateModified: string;
}
export default class FBlob {
blobId: string;
/**
* can either contain the whole content (in e.g. string notes), only part (large text notes) or nothing at all (binary notes, images)
*/
content: string;
contentLength: number;
dateModified: string;
utcDateModified: string;
constructor(row: FBlobRow) {
this.blobId = row.blobId;
this.content = row.content;
this.contentLength = row.contentLength;
this.dateModified = row.dateModified;
this.utcDateModified = row.utcDateModified;
}
/**
* @throws Error in case of invalid JSON
*/
getJsonContent(): unknown {
if (!this.content || !this.content.trim()) {
return null;
}
return JSON.parse(this.content);
}
getJsonContentSafely(): unknown | null {
try {
return this.getJsonContent();
}
catch (e) {
return null;
}
}
}

@ -1,51 +1,65 @@
import { Froca } from "../services/froca-interface.js";
export interface FBranchRow {
branchId: string;
noteId: string;
parentNoteId: string;
notePosition: number;
prefix?: string;
isExpanded?: boolean;
fromSearchNote: boolean;
}
/** /**
* Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple * Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple
* parents. * parents.
*/ */
class FBranch { class FBranch {
constructor(froca, row) { private froca: Froca;
/** @type {Froca} */
/**
* primary key
*/
branchId!: string;
noteId!: string;
parentNoteId!: string;
notePosition!: number;
prefix?: string;
isExpanded?: boolean;
fromSearchNote!: boolean;
constructor(froca: Froca, row: FBranchRow) {
this.froca = froca; this.froca = froca;
this.update(row); this.update(row);
} }
update(row) { update(row: FBranchRow) {
/** /**
* primary key * primary key
* @type {string}
*/ */
this.branchId = row.branchId; this.branchId = row.branchId;
/** @type {string} */
this.noteId = row.noteId; this.noteId = row.noteId;
/** @type {string} */
this.parentNoteId = row.parentNoteId; this.parentNoteId = row.parentNoteId;
/** @type {int} */
this.notePosition = row.notePosition; this.notePosition = row.notePosition;
/** @type {string} */
this.prefix = row.prefix; this.prefix = row.prefix;
/** @type {boolean} */
this.isExpanded = !!row.isExpanded; this.isExpanded = !!row.isExpanded;
/** @type {boolean} */
this.fromSearchNote = !!row.fromSearchNote; this.fromSearchNote = !!row.fromSearchNote;
} }
/** @returns {FNote} */
async getNote() { async getNote() {
return this.froca.getNote(this.noteId); return this.froca.getNote(this.noteId);
} }
/** @returns {FNote} */
getNoteFromCache() { getNoteFromCache() {
return this.froca.getNoteFromCache(this.noteId); return this.froca.getNoteFromCache(this.noteId);
} }
/** @returns {FNote} */
async getParentNote() { async getParentNote() {
return this.froca.getNote(this.parentNoteId); return this.froca.getNote(this.parentNoteId);
} }
/** @returns {boolean} true if it's top level, meaning its parent is the root note */ /** @returns true if it's top level, meaning its parent is the root note */
isTopLevel() { isTopLevel() {
return this.parentNoteId === 'root'; return this.parentNoteId === 'root';
} }
@ -54,8 +68,8 @@ class FBranch {
return `FBranch(branchId=${this.branchId})`; return `FBranch(branchId=${this.branchId})`;
} }
get pojo() { get pojo(): Omit<FBranch, "froca"> {
const pojo = {...this}; const pojo = {...this} as any;
delete pojo.froca; delete pojo.froca;
return pojo; return pojo;
} }

@ -4,6 +4,9 @@ import ws from "../services/ws.js";
import froca from "../services/froca.js"; import froca from "../services/froca.js";
import protectedSessionHolder from "../services/protected_session_holder.js"; import protectedSessionHolder from "../services/protected_session_holder.js";
import cssClassManager from "../services/css_class_manager.js"; import cssClassManager from "../services/css_class_manager.js";
import { Froca } from '../services/froca-interface.js';
import FAttachment from './fattachment.js';
import FAttribute, { AttributeType } from './fattribute.js';
const LABEL = 'label'; const LABEL = 'label';
const RELATION = 'relation'; const RELATION = 'relation';
@ -30,76 +33,91 @@ const NOTE_TYPE_ICONS = {
* There are many different Note types, some of which are entirely opaque to the * There are many different Note types, some of which are entirely opaque to the
* end user. Those types should be used only for checking against, they are * end user. Those types should be used only for checking against, they are
* not for direct use. * not for direct use.
* @typedef {"file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code"} NoteType
*/ */
type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code";
/** interface NotePathRecord {
* @typedef {Object} NotePathRecord isArchived: boolean;
* @property {boolean} isArchived isInHoistedSubTree: boolean;
* @property {boolean} isInHoistedSubTree isSearch: boolean;
* @property {boolean} isSearch notePath: string[];
* @property {Array<string>} notePath isHidden: boolean;
* @property {boolean} isHidden }
*/
export interface FNoteRow {
noteId: string;
title: string;
isProtected: boolean;
type: NoteType;
mime: string;
blobId: string;
}
export interface NoteMetaData {
dateCreated: string;
utcDateCreated: string;
dateModified: string;
utcDateModified: string;
}
/** /**
* Note is the main node and concept in Trilium. * Note is the main node and concept in Trilium.
*/ */
class FNote { class FNote {
private froca: Froca;
noteId!: string;
title!: string;
isProtected!: boolean;
type!: NoteType;
/** /**
* @param {Froca} froca * content-type, e.g. "application/json"
* @param {Object.<string, Object>} row
*/ */
constructor(froca, row) { mime!: string;
/** @type {Froca} */ // the main use case to keep this is to detect content change which should trigger refresh
this.froca = froca; blobId!: string;
/** @type {string[]} */ attributes: string[];
this.attributes = []; targetRelations: string[];
parents: string[];
children: string[];
/** @type {string[]} */ parentToBranch: Record<string, string>;
this.targetRelations = []; childToBranch: Record<string, string>;
attachments: FAttachment[] | null;
// Managed by Froca.
searchResultsLoaded?: boolean;
highlightedTokens?: unknown;
/** @type {string[]} */ constructor(froca: Froca, row: FNoteRow) {
this.froca = froca;
this.attributes = [];
this.targetRelations = [];
this.parents = []; this.parents = [];
/** @type {string[]} */
this.children = []; this.children = [];
/** @type {Object.<string, string>} */
this.parentToBranch = {}; this.parentToBranch = {};
/** @type {Object.<string, string>} */
this.childToBranch = {}; this.childToBranch = {};
/** @type {FAttachment[]|null} */
this.attachments = null; // lazy loaded this.attachments = null; // lazy loaded
this.update(row); this.update(row);
} }
update(row) { update(row: FNoteRow) {
/** @type {string} */
this.noteId = row.noteId; this.noteId = row.noteId;
/** @type {string} */
this.title = row.title; this.title = row.title;
/** @type {boolean} */
this.isProtected = !!row.isProtected; this.isProtected = !!row.isProtected;
/**
* See {@see NoteType} for info on values.
* @type {NoteType}
*/
this.type = row.type; this.type = row.type;
/**
* content-type, e.g. "application/json"
* @type {string}
*/
this.mime = row.mime; this.mime = row.mime;
// the main use case to keep this is to detect content change which should trigger refresh
this.blobId = row.blobId; this.blobId = row.blobId;
} }
addParent(parentNoteId, branchId, sort = true) { addParent(parentNoteId: string, branchId: string, sort = true) {
if (parentNoteId === 'none') { if (parentNoteId === 'none') {
return; return;
} }
@ -115,7 +133,7 @@ class FNote {
} }
} }
addChild(childNoteId, branchId, sort = true) { addChild(childNoteId: string, branchId: string, sort = true) {
if (!(childNoteId in this.childToBranch)) { if (!(childNoteId in this.childToBranch)) {
this.children.push(childNoteId); this.children.push(childNoteId);
} }
@ -128,16 +146,18 @@ class FNote {
} }
sortChildren() { sortChildren() {
const branchIdPos = {}; const branchIdPos: Record<string, number> = {};
for (const branchId of Object.values(this.childToBranch)) { for (const branchId of Object.values(this.childToBranch)) {
branchIdPos[branchId] = this.froca.getBranch(branchId).notePosition; const notePosition = this.froca.getBranch(branchId)?.notePosition;
if (notePosition) {
branchIdPos[branchId] = notePosition;
}
} }
this.children.sort((a, b) => branchIdPos[this.childToBranch[a]] - branchIdPos[this.childToBranch[b]]); this.children.sort((a, b) => branchIdPos[this.childToBranch[a]] - branchIdPos[this.childToBranch[b]]);
} }
/** @returns {boolean} */
isJson() { isJson() {
return this.mime === "application/json"; return this.mime === "application/json";
} }
@ -151,34 +171,32 @@ class FNote {
async getJsonContent() { async getJsonContent() {
const content = await this.getContent(); const content = await this.getContent();
if (typeof content !== "string") {
console.log(`Unknown note content for '${this.noteId}'.`);
return null;
}
try { try {
return JSON.parse(content); return JSON.parse(content);
} }
catch (e) { catch (e: any) {
console.log(`Cannot parse content of note '${this.noteId}': `, e.message); console.log(`Cannot parse content of note '${this.noteId}': `, e.message);
return null; return null;
} }
} }
/**
* @returns {string[]}
*/
getParentBranchIds() { getParentBranchIds() {
return Object.values(this.parentToBranch); return Object.values(this.parentToBranch);
} }
/** /**
* @returns {string[]}
* @deprecated use getParentBranchIds() instead * @deprecated use getParentBranchIds() instead
*/ */
getBranchIds() { getBranchIds() {
return this.getParentBranchIds(); return this.getParentBranchIds();
} }
/**
* @returns {FBranch[]}
*/
getParentBranches() { getParentBranches() {
const branchIds = Object.values(this.parentToBranch); const branchIds = Object.values(this.parentToBranch);
@ -186,19 +204,16 @@ class FNote {
} }
/** /**
* @returns {FBranch[]}
* @deprecated use getParentBranches() instead * @deprecated use getParentBranches() instead
*/ */
getBranches() { getBranches() {
return this.getParentBranches(); return this.getParentBranches();
} }
/** @returns {boolean} */
hasChildren() { hasChildren() {
return this.children.length > 0; return this.children.length > 0;
} }
/** @returns {FBranch[]} */
getChildBranches() { getChildBranches() {
// don't use Object.values() to guarantee order // don't use Object.values() to guarantee order
const branchIds = this.children.map(childNoteId => this.childToBranch[childNoteId]); const branchIds = this.children.map(childNoteId => this.childToBranch[childNoteId]);
@ -206,12 +221,10 @@ class FNote {
return this.froca.getBranches(branchIds); return this.froca.getBranches(branchIds);
} }
/** @returns {string[]} */
getParentNoteIds() { getParentNoteIds() {
return this.parents; return this.parents;
} }
/** @returns {FNote[]} */
getParentNotes() { getParentNotes() {
return this.froca.getNotesFromCache(this.parents); return this.froca.getNotesFromCache(this.parents);
} }
@ -240,17 +253,14 @@ class FNote {
return this.hasAttribute('label', 'archived'); return this.hasAttribute('label', 'archived');
} }
/** @returns {string[]} */
getChildNoteIds() { getChildNoteIds() {
return this.children; return this.children;
} }
/** @returns {Promise<FNote[]>} */
async getChildNotes() { async getChildNotes() {
return await this.froca.getNotes(this.children); return await this.froca.getNotes(this.children);
} }
/** @returns {Promise<FAttachment[]>} */
async getAttachments() { async getAttachments() {
if (!this.attachments) { if (!this.attachments) {
this.attachments = await this.froca.getAttachmentsForNote(this.noteId); this.attachments = await this.froca.getAttachmentsForNote(this.noteId);
@ -259,14 +269,12 @@ class FNote {
return this.attachments; return this.attachments;
} }
/** @returns {Promise<FAttachment[]>} */ async getAttachmentsByRole(role: string) {
async getAttachmentsByRole(role) {
return (await this.getAttachments()) return (await this.getAttachments())
.filter(attachment => attachment.role === role); .filter(attachment => attachment.role === role);
} }
/** @returns {Promise<FAttachment>} */ async getAttachmentById(attachmentId: string) {
async getAttachmentById(attachmentId) {
const attachments = await this.getAttachments(); const attachments = await this.getAttachments();
return attachments.find(att => att.attachmentId === attachmentId); return attachments.find(att => att.attachmentId === attachmentId);
@ -296,11 +304,11 @@ class FNote {
} }
/** /**
* @param {string} [type] - (optional) attribute type to filter * @param [type] - attribute type to filter
* @param {string} [name] - (optional) attribute name to filter * @param [name] - attribute name to filter
* @returns {FAttribute[]} all note's attributes, including inherited ones * @returns all note's attributes, including inherited ones
*/ */
getOwnedAttributes(type, name) { getOwnedAttributes(type?: AttributeType, name?: string) {
const attrs = this.attributes const attrs = this.attributes
.map(attributeId => this.froca.attributes[attributeId]) .map(attributeId => this.froca.attributes[attributeId])
.filter(Boolean); // filter out nulls; .filter(Boolean); // filter out nulls;
@ -309,20 +317,18 @@ class FNote {
} }
/** /**
* @param {string} [type] - (optional) attribute type to filter * @param [type] - attribute type to filter
* @param {string} [name] - (optional) attribute name to filter * @param [name] - attribute name to filter
* @returns {FAttribute[]} all note's attributes, including inherited ones * @returns all note's attributes, including inherited ones
*/ */
getAttributes(type, name) { getAttributes(type?: AttributeType, name?: string) {
return this.__filterAttrs(this.__getCachedAttributes([]), type, name); return this.__filterAttrs(this.__getCachedAttributes([]), type, name);
} }
/** /**
* @param {string[]} path
* @return {FAttribute[]}
* @private * @private
*/ */
__getCachedAttributes(path) { __getCachedAttributes(path: string[]): FAttribute[] {
// notes/clones cannot form tree cycles, it is possible to create attribute inheritance cycle via templates // notes/clones cannot form tree cycles, it is possible to create attribute inheritance cycle via templates
// when template instance is a parent of template itself // when template instance is a parent of template itself
if (path.includes(this.noteId)) { if (path.includes(this.noteId)) {
@ -377,9 +383,9 @@ class FNote {
/** /**
* Gives all possible note paths leading to this note. Paths containing search note are ignored (could form cycles) * Gives all possible note paths leading to this note. Paths containing search note are ignored (could form cycles)
* *
* @returns {string[][]} - array of notePaths (each represented by array of noteIds constituting the particular note path) * @returns array of notePaths (each represented by array of noteIds constituting the particular note path)
*/ */
getAllNotePaths() { getAllNotePaths(): string[][] {
if (this.noteId === 'root') { if (this.noteId === 'root') {
return [['root']]; return [['root']];
} }
@ -397,10 +403,6 @@ class FNote {
return notePaths; return notePaths;
} }
/**
* @param {string} [hoistedNoteId='root']
* @return {Array<NotePathRecord>}
*/
getSortedNotePathRecords(hoistedNoteId = 'root') { getSortedNotePathRecords(hoistedNoteId = 'root') {
const isHoistedRoot = hoistedNoteId === 'root'; const isHoistedRoot = hoistedNoteId === 'root';
@ -477,13 +479,9 @@ class FNote {
} }
/** /**
* @param {FAttribute[]} attributes
* @param {AttributeType} type
* @param {string} name
* @return {FAttribute[]}
* @private * @private
*/ */
__filterAttrs(attributes, type, name) { __filterAttrs(attributes: FAttribute[], type?: AttributeType, name?: string): FAttribute[] {
this.__validateTypeName(type, name); this.__validateTypeName(type, name);
if (!type && !name) { if (!type && !name) {
@ -495,15 +493,17 @@ class FNote {
} else if (name) { } else if (name) {
return attributes.filter(attr => attr.name === name); return attributes.filter(attr => attr.name === name);
} }
return [];
} }
__getInheritableAttributes(path) { __getInheritableAttributes(path: string[]) {
const attrs = this.__getCachedAttributes(path); const attrs = this.__getCachedAttributes(path);
return attrs.filter(attr => attr.isInheritable); return attrs.filter(attr => attr.isInheritable);
} }
__validateTypeName(type, name) { __validateTypeName(type?: string, name?: string) {
if (type && type !== 'label' && type !== 'relation') { if (type && type !== 'label' && type !== 'relation') {
throw new Error(`Unrecognized attribute type '${type}'. Only 'label' and 'relation' are possible values.`); throw new Error(`Unrecognized attribute type '${type}'. Only 'label' and 'relation' are possible values.`);
} }
@ -517,18 +517,18 @@ class FNote {
} }
/** /**
* @param {string} [name] - label name to filter * @param [name] - label name to filter
* @returns {FAttribute[]} all note's labels (attributes with type label), including inherited ones * @returns all note's labels (attributes with type label), including inherited ones
*/ */
getOwnedLabels(name) { getOwnedLabels(name: string) {
return this.getOwnedAttributes(LABEL, name); return this.getOwnedAttributes(LABEL, name);
} }
/** /**
* @param {string} [name] - label name to filter * @param [name] - label name to filter
* @returns {FAttribute[]} all note's labels (attributes with type label), including inherited ones * @returns all note's labels (attributes with type label), including inherited ones
*/ */
getLabels(name) { getLabels(name: string) {
return this.getAttributes(LABEL, name); return this.getAttributes(LABEL, name);
} }
@ -536,7 +536,7 @@ class FNote {
const iconClassLabels = this.getLabels('iconClass'); const iconClassLabels = this.getLabels('iconClass');
const workspaceIconClass = this.getWorkspaceIconClass(); const workspaceIconClass = this.getWorkspaceIconClass();
if (iconClassLabels.length > 0) { if (iconClassLabels && iconClassLabels.length > 0) {
return iconClassLabels[0].value; return iconClassLabels[0].value;
} }
else if (workspaceIconClass) { else if (workspaceIconClass) {
@ -579,7 +579,7 @@ class FNote {
if (!childBranches) { if (!childBranches) {
ws.logError(`No children for '${this.noteId}'. This shouldn't happen.`); ws.logError(`No children for '${this.noteId}'. This shouldn't happen.`);
return; return [];
} }
// we're not checking hideArchivedNotes since that would mean we need to lazy load the child notes // we're not checking hideArchivedNotes since that would mean we need to lazy load the child notes
@ -591,102 +591,104 @@ class FNote {
} }
/** /**
* @param {string} [name] - relation name to filter * @param [name] - relation name to filter
* @returns {FAttribute[]} all note's relations (attributes with type relation), including inherited ones * @returns all note's relations (attributes with type relation), including inherited ones
*/ */
getOwnedRelations(name) { getOwnedRelations(name: string) {
return this.getOwnedAttributes(RELATION, name); return this.getOwnedAttributes(RELATION, name);
} }
/** /**
* @param {string} [name] - relation name to filter * @param [name] - relation name to filter
* @returns {FAttribute[]} all note's relations (attributes with type relation), including inherited ones * @returns all note's relations (attributes with type relation), including inherited ones
*/ */
getRelations(name) { getRelations(name: string) {
return this.getAttributes(RELATION, name); return this.getAttributes(RELATION, name);
} }
/** /**
* @param {AttributeType} type - attribute type (label, relation, etc.) * @param type - attribute type (label, relation, etc.)
* @param {string} name - attribute name * @param name - attribute name
* @returns {boolean} true if note has an attribute with given type and name (including inherited) * @returns true if note has an attribute with given type and name (including inherited)
*/ */
hasAttribute(type, name) { hasAttribute(type: AttributeType, name: string) {
const attributes = this.getAttributes(); const attributes = this.getAttributes();
return attributes.some(attr => attr.name === name && attr.type === type); return attributes.some(attr => attr.name === name && attr.type === type);
} }
/** /**
* @param {AttributeType} type - attribute type (label, relation, etc.) * @param type - attribute type (label, relation, etc.)
* @param {string} name - attribute name * @param name - attribute name
* @returns {boolean} true if note has an attribute with given type and name (including inherited) * @returns true if note has an attribute with given type and name (including inherited)
*/ */
hasOwnedAttribute(type, name) { hasOwnedAttribute(type: AttributeType, name: string) {
return !!this.getOwnedAttribute(type, name); return !!this.getOwnedAttribute(type, name);
} }
/** /**
* @param {AttributeType} type - attribute type (label, relation, etc.) * @param type - attribute type (label, relation, etc.)
* @param {string} name - attribute name * @param name - attribute name
* @returns {FAttribute} attribute of the given type and name. If there are more such attributes, first is returned. Returns null if there's no such attribute belonging to this note. * @returns attribute of the given type and name. If there are more such attributes, first is returned. Returns null if there's no such attribute belonging to this note.
*/ */
getOwnedAttribute(type, name) { getOwnedAttribute(type: AttributeType, name: string) {
const attributes = this.getOwnedAttributes(); const attributes = this.getOwnedAttributes();
return attributes.find(attr => attr.name === name && attr.type === type); return attributes.find(attr => attr.name === name && attr.type === type);
} }
/** /**
* @param {AttributeType} type - attribute type (label, relation, etc.) * @param type - attribute type (label, relation, etc.)
* @param {string} name - attribute name * @param name - attribute name
* @returns {FAttribute} attribute of the given type and name. If there are more such attributes, first is returned. Returns null if there's no such attribute belonging to this note. * @returns attribute of the given type and name. If there are more such attributes, first is returned. Returns null if there's no such attribute belonging to this note.
*/ */
getAttribute(type, name) { getAttribute(type: AttributeType, name: string) {
const attributes = this.getAttributes(); const attributes = this.getAttributes();
return attributes.find(attr => attr.name === name && attr.type === type); return attributes.find(attr => attr.name === name && attr.type === type);
} }
/** /**
* @param {AttributeType} type - attribute type (label, relation, etc.) * @param type - attribute type (label, relation, etc.)
* @param {string} name - attribute name * @param name - attribute name
* @returns {string} attribute value of the given type and name or null if no such attribute exists. * @returns attribute value of the given type and name or null if no such attribute exists.
*/ */
getOwnedAttributeValue(type, name) { getOwnedAttributeValue(type: AttributeType, name: string) {
const attr = this.getOwnedAttribute(type, name); const attr = this.getOwnedAttribute(type, name);
return attr ? attr.value : null; return attr ? attr.value : null;
} }
/** /**
* @param {AttributeType} type - attribute type (label, relation, etc.) * @param type - attribute type (label, relation, etc.)
* @param {string} name - attribute name * @param name - attribute name
* @returns {string} attribute value of the given type and name or null if no such attribute exists. * @returns attribute value of the given type and name or null if no such attribute exists.
*/ */
getAttributeValue(type, name) { getAttributeValue(type: AttributeType, name: string) {
const attr = this.getAttribute(type, name); const attr = this.getAttribute(type, name);
return attr ? attr.value : null; return attr ? attr.value : null;
} }
/** /**
* @param {string} name - label name * @param name - label name
* @returns {boolean} true if label exists (excluding inherited) * @returns true if label exists (excluding inherited)
*/ */
hasOwnedLabel(name) { return this.hasOwnedAttribute(LABEL, name); } hasOwnedLabel(name: string) {
return this.hasOwnedAttribute(LABEL, name);
}
/** /**
* @param {string} name - label name * @param name - label name
* @returns {boolean} true if label exists (including inherited) * @returns true if label exists (including inherited)
*/ */
hasLabel(name) { return this.hasAttribute(LABEL, name); } hasLabel(name: string) { return this.hasAttribute(LABEL, name); }
/** /**
* @param {string} name - label name * @param name - label name
* @returns {boolean} true if label exists (including inherited) and does not have "false" value. * @returns true if label exists (including inherited) and does not have "false" value.
*/ */
isLabelTruthy(name) { isLabelTruthy(name: string) {
const label = this.getLabel(name); const label = this.getLabel(name);
if (!label) { if (!label) {
@ -697,80 +699,79 @@ class FNote {
} }
/** /**
* @param {string} name - relation name * @param name - relation name
* @returns {boolean} true if relation exists (excluding inherited) * @returns true if relation exists (excluding inherited)
*/ */
hasOwnedRelation(name) { return this.hasOwnedAttribute(RELATION, name); } hasOwnedRelation(name: string) { return this.hasOwnedAttribute(RELATION, name); }
/** /**
* @param {string} name - relation name * @param name - relation name
* @returns {boolean} true if relation exists (including inherited) * @returns true if relation exists (including inherited)
*/ */
hasRelation(name) { return this.hasAttribute(RELATION, name); } hasRelation(name: string) { return this.hasAttribute(RELATION, name); }
/** /**
* @param {string} name - label name * @param name - label name
* @returns {FAttribute} label if it exists, null otherwise * @returns label if it exists, null otherwise
*/ */
getOwnedLabel(name) { return this.getOwnedAttribute(LABEL, name); } getOwnedLabel(name: string) { return this.getOwnedAttribute(LABEL, name); }
/** /**
* @param {string} name - label name * @param name - label name
* @returns {FAttribute} label if it exists, null otherwise * @returns label if it exists, null otherwise
*/ */
getLabel(name) { return this.getAttribute(LABEL, name); } getLabel(name: string) { return this.getAttribute(LABEL, name); }
/** /**
* @param {string} name - relation name * @param name - relation name
* @returns {FAttribute} relation if it exists, null otherwise * @returns relation if it exists, null otherwise
*/ */
getOwnedRelation(name) { return this.getOwnedAttribute(RELATION, name); } getOwnedRelation(name: string) { return this.getOwnedAttribute(RELATION, name); }
/** /**
* @param {string} name - relation name * @param name - relation name
* @returns {FAttribute} relation if it exists, null otherwise * @returns relation if it exists, null otherwise
*/ */
getRelation(name) { return this.getAttribute(RELATION, name); } getRelation(name: string) { return this.getAttribute(RELATION, name); }
/** /**
* @param {string} name - label name * @param name - label name
* @returns {string} label value if label exists, null otherwise * @returns label value if label exists, null otherwise
*/ */
getOwnedLabelValue(name) { return this.getOwnedAttributeValue(LABEL, name); } getOwnedLabelValue(name: string) { return this.getOwnedAttributeValue(LABEL, name); }
/** /**
* @param {string} name - label name * @param name - label name
* @returns {string} label value if label exists, null otherwise * @returns label value if label exists, null otherwise
*/ */
getLabelValue(name) { return this.getAttributeValue(LABEL, name); } getLabelValue(name: string) { return this.getAttributeValue(LABEL, name); }
/** /**
* @param {string} name - relation name * @param name - relation name
* @returns {string} relation value if relation exists, null otherwise * @returns relation value if relation exists, null otherwise
*/ */
getOwnedRelationValue(name) { return this.getOwnedAttributeValue(RELATION, name); } getOwnedRelationValue(name: string) { return this.getOwnedAttributeValue(RELATION, name); }
/** /**
* @param {string} name - relation name * @param name - relation name
* @returns {string} relation value if relation exists, null otherwise * @returns relation value if relation exists, null otherwise
*/ */
getRelationValue(name) { return this.getAttributeValue(RELATION, name); } getRelationValue(name: string) { return this.getAttributeValue(RELATION, name); }
/** /**
* @param {string} name * @param name
* @returns {Promise<FNote>|null} target note of the relation or null (if target is empty or note was not found) * @returns target note of the relation or null (if target is empty or note was not found)
*/ */
async getRelationTarget(name) { async getRelationTarget(name: string) {
const targets = await this.getRelationTargets(name); const targets = await this.getRelationTargets(name);
return targets.length > 0 ? targets[0] : null; return targets.length > 0 ? targets[0] : null;
} }
/** /**
* @param {string} [name] - relation name to filter * @param [name] - relation name to filter
* @returns {Promise<FNote[]>}
*/ */
async getRelationTargets(name) { async getRelationTargets(name: string) {
const relations = this.getRelations(name); const relations = this.getRelations(name);
const targets = []; const targets = [];
@ -781,9 +782,6 @@ class FNote {
return targets; return targets;
} }
/**
* @returns {FNote[]}
*/
getNotesToInheritAttributesFrom() { getNotesToInheritAttributesFrom() {
const relations = [ const relations = [
...this.getRelations('template'), ...this.getRelations('template'),
@ -819,7 +817,7 @@ class FNote {
return promotedAttrs; return promotedAttrs;
} }
hasAncestor(ancestorNoteId, followTemplates = false, visitedNoteIds = null) { hasAncestor(ancestorNoteId: string, followTemplates = false, visitedNoteIds: Set<string> | null = null) {
if (this.noteId === ancestorNoteId) { if (this.noteId === ancestorNoteId) {
return true; return true;
} }
@ -861,8 +859,6 @@ class FNote {
/** /**
* Get relations which target this note * Get relations which target this note
*
* @returns {FAttribute[]}
*/ */
getTargetRelations() { getTargetRelations() {
return this.targetRelations return this.targetRelations
@ -871,8 +867,6 @@ class FNote {
/** /**
* Get relations which target this note * Get relations which target this note
*
* @returns {Promise<FNote[]>}
*/ */
async getTargetRelationSourceNotes() { async getTargetRelationSourceNotes() {
const targetRelations = this.getTargetRelations(); const targetRelations = this.getTargetRelations();
@ -882,13 +876,11 @@ class FNote {
/** /**
* @deprecated use getBlob() instead * @deprecated use getBlob() instead
* @return {Promise<FBlob>}
*/ */
async getNoteComplement() { async getNoteComplement() {
return this.getBlob(); return this.getBlob();
} }
/** @return {Promise<FBlob>} */
async getBlob() { async getBlob() {
return await this.froca.getBlob('notes', this.noteId); return await this.froca.getBlob('notes', this.noteId);
} }
@ -897,8 +889,8 @@ class FNote {
return `Note(noteId=${this.noteId}, title=${this.title})`; return `Note(noteId=${this.noteId}, title=${this.title})`;
} }
get dto() { get dto(): Omit<FNote, "froca"> {
const dto = Object.assign({}, this); const dto = Object.assign({}, this) as any;
delete dto.froca; delete dto.froca;
return dto; return dto;
@ -919,7 +911,7 @@ class FNote {
return labels.length > 0 ? labels[0].value : ""; return labels.length > 0 ? labels[0].value : "";
} }
/** @returns {boolean} true if this note is JavaScript (code or file) */ /** @returns true if this note is JavaScript (code or file) */
isJavaScript() { isJavaScript() {
return (this.type === "code" || this.type === "file" || this.type === 'launcher') return (this.type === "code" || this.type === "file" || this.type === 'launcher')
&& (this.mime.startsWith("application/javascript") && (this.mime.startsWith("application/javascript")
@ -927,12 +919,12 @@ class FNote {
|| this.mime === "text/javascript"); || this.mime === "text/javascript");
} }
/** @returns {boolean} true if this note is HTML */ /** @returns true if this note is HTML */
isHtml() { isHtml() {
return (this.type === "code" || this.type === "file" || this.type === "render") && this.mime === "text/html"; return (this.type === "code" || this.type === "file" || this.type === "render") && this.mime === "text/html";
} }
/** @returns {string|null} JS script environment - either "frontend" or "backend" */ /** @returns JS script environment - either "frontend" or "backend" */
getScriptEnv() { getScriptEnv() {
if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith('env=frontend'))) { if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith('env=frontend'))) {
return "frontend"; return "frontend";
@ -959,11 +951,9 @@ class FNote {
if (env === "frontend") { if (env === "frontend") {
const bundleService = (await import("../services/bundle.js")).default; const bundleService = (await import("../services/bundle.js")).default;
return await bundleService.getAndExecuteBundle(this.noteId); return await bundleService.getAndExecuteBundle(this.noteId);
} } else if (env === "backend") {
else if (env === "backend") { await server.post(`script/run/${this.noteId}`);
const resp = await server.post(`script/run/${this.noteId}`); } else {
}
else {
throw new Error(`Unrecognized env type ${env} for note ${this.noteId}`); throw new Error(`Unrecognized env type ${env} for note ${this.noteId}`);
} }
} }
@ -1002,11 +992,9 @@ class FNote {
/** /**
* Provides note's date metadata. * Provides note's date metadata.
*
* @returns {Promise<{dateCreated: string, utcDateCreated: string, dateModified: string, utcDateModified: string}>}
*/ */
async getMetadata() { async getMetadata() {
return await server.get(`notes/${this.noteId}/metadata`); return await server.get<NoteMetaData>(`notes/${this.noteId}/metadata`);
} }
} }

@ -1,6 +1,6 @@
const registeredClasses = new Set(); const registeredClasses = new Set<string>();
function createClassForColor(color) { function createClassForColor(color: string | null) {
if (!color?.trim()) { if (!color?.trim()) {
return ""; return "";
} }

@ -1,67 +1,61 @@
import dayjs from "dayjs";
import { FNoteRow } from "../entities/fnote.js";
import froca from "./froca.js"; import froca from "./froca.js";
import server from "./server.js"; import server from "./server.js";
import ws from "./ws.js"; import ws from "./ws.js";
/** @returns {FNote} */
async function getInboxNote() { async function getInboxNote() {
const note = await server.get(`special-notes/inbox/${dayjs().format("YYYY-MM-DD")}`, "date-note"); const note = await server.get<FNoteRow>(`special-notes/inbox/${dayjs().format("YYYY-MM-DD")}`, "date-note");
return await froca.getNote(note.noteId); return await froca.getNote(note.noteId);
} }
/** @returns {FNote} */
async function getTodayNote() { async function getTodayNote() {
return await getDayNote(dayjs().format("YYYY-MM-DD")); return await getDayNote(dayjs().format("YYYY-MM-DD"));
} }
/** @returns {FNote} */ async function getDayNote(date: string) {
async function getDayNote(date) { const note = await server.get<FNoteRow>(`special-notes/days/${date}`, "date-note");
const note = await server.get(`special-notes/days/${date}`, "date-note");
await ws.waitForMaxKnownEntityChangeId(); await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId); return await froca.getNote(note.noteId);
} }
/** @returns {FNote} */ async function getWeekNote(date: string) {
async function getWeekNote(date) { const note = await server.get<FNoteRow>(`special-notes/weeks/${date}`, "date-note");
const note = await server.get(`special-notes/weeks/${date}`, "date-note");
await ws.waitForMaxKnownEntityChangeId(); await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId); return await froca.getNote(note.noteId);
} }
/** @returns {FNote} */ async function getMonthNote(month: string) {
async function getMonthNote(month) { const note = await server.get<FNoteRow>(`special-notes/months/${month}`, "date-note");
const note = await server.get(`special-notes/months/${month}`, "date-note");
await ws.waitForMaxKnownEntityChangeId(); await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId); return await froca.getNote(note.noteId);
} }
/** @returns {FNote} */ async function getYearNote(year: string) {
async function getYearNote(year) { const note = await server.get<FNoteRow>(`special-notes/years/${year}`, "date-note");
const note = await server.get(`special-notes/years/${year}`, "date-note");
await ws.waitForMaxKnownEntityChangeId(); await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId); return await froca.getNote(note.noteId);
} }
/** @returns {FNote} */
async function createSqlConsole() { async function createSqlConsole() {
const note = await server.post('special-notes/sql-console'); const note = await server.post<FNoteRow>('special-notes/sql-console');
await ws.waitForMaxKnownEntityChangeId(); await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId); return await froca.getNote(note.noteId);
} }
/** @returns {FNote} */
async function createSearchNote(opts = {}) { async function createSearchNote(opts = {}) {
const note = await server.post('special-notes/search-note', opts); const note = await server.post<FNoteRow>('special-notes/search-note', opts);
await ws.waitForMaxKnownEntityChangeId(); await ws.waitForMaxKnownEntityChangeId();

@ -0,0 +1,24 @@
import FAttachment from "../entities/fattachment.js";
import FAttribute from "../entities/fattribute.js";
import FBlob from "../entities/fblob.js";
import FBranch from "../entities/fbranch.js";
import FNote from "../entities/fnote.js";
export interface Froca {
notes: Record<string, FNote>;
branches: Record<string, FBranch>;
attributes: Record<string, FAttribute>;
attachments: Record<string, FAttachment>;
blobPromises: Record<string, Promise<void | FBlob> | null>;
getBlob(entityType: string, entityId: string): Promise<void | FBlob | null>;
getNote(noteId: string, silentNotFoundError?: boolean): Promise<FNote | null>;
getNoteFromCache(noteId: string): FNote;
getNotesFromCache(noteIds: string[], silentNotFoundError?: boolean): FNote[];
getNotes(noteIds: string[], silentNotFoundError?: boolean): Promise<FNote[]>;
getBranch(branchId: string, silentNotFoundError?: boolean): FBranch | undefined;
getBranches(branchIds: string[], silentNotFoundError?: boolean): FBranch[];
getAttachmentsForNote(noteId: string): Promise<FAttachment[]>;
}

@ -1,10 +1,24 @@
import FBranch from "../entities/fbranch.js"; import FBranch, { FBranchRow } from "../entities/fbranch.js";
import FNote from "../entities/fnote.js"; import FNote, { FNoteRow } from "../entities/fnote.js";
import FAttribute from "../entities/fattribute.js"; import FAttribute, { FAttributeRow } from "../entities/fattribute.js";
import server from "./server.js"; import server from "./server.js";
import appContext from "../components/app_context.js"; import appContext from "../components/app_context.js";
import FBlob from "../entities/fblob.js"; import FBlob, { FBlobRow } from "../entities/fblob.js";
import FAttachment from "../entities/fattachment.js"; import FAttachment, { FAttachmentRow } from "../entities/fattachment.js";
import { Froca } from "./froca-interface.js";
interface SubtreeResponse {
notes: FNoteRow[];
branches: FBranchRow[];
attributes: FAttributeRow[];
}
interface SearchNoteResponse {
searchResultNoteIds: string[];
highlightedTokens: string[];
error: string | null;
}
/** /**
* Froca (FROntend CAche) keeps a read only cache of note tree structure in frontend's memory. * Froca (FROntend CAche) keeps a read only cache of note tree structure in frontend's memory.
@ -16,48 +30,47 @@ import FAttachment from "../entities/fattachment.js";
* *
* Backend has a similar cache called Becca * Backend has a similar cache called Becca
*/ */
class Froca { class FrocaImpl implements Froca {
initializedPromise: Promise<void>;
notes!: Record<string, FNote>;
branches!: Record<string, FBranch>;
attributes!: Record<string, FAttribute>;
attachments!: Record<string, FAttachment>;
blobPromises!: Record<string, Promise<void | FBlob> | null>;
constructor() { constructor() {
this.initializedPromise = this.loadInitialTree(); this.initializedPromise = this.loadInitialTree();
} }
async loadInitialTree() { async loadInitialTree() {
const resp = await server.get('tree'); const resp = await server.get<SubtreeResponse>('tree');
// clear the cache only directly before adding new content which is important for e.g., switching to protected session // clear the cache only directly before adding new content which is important for e.g., switching to protected session
/** @type {Object.<string, FNote>} */
this.notes = {}; this.notes = {};
/** @type {Object.<string, FBranch>} */
this.branches = {}; this.branches = {};
/** @type {Object.<string, FAttribute>} */
this.attributes = {}; this.attributes = {};
/** @type {Object.<string, FAttachment>} */
this.attachments = {}; this.attachments = {};
/** @type {Object.<string, Promise<FBlob>>} */
this.blobPromises = {}; this.blobPromises = {};
this.addResp(resp); this.addResp(resp);
} }
async loadSubTree(subTreeNoteId) { async loadSubTree(subTreeNoteId: string) {
const resp = await server.get(`tree?subTreeNoteId=${subTreeNoteId}`); const resp = await server.get<SubtreeResponse>(`tree?subTreeNoteId=${subTreeNoteId}`);
this.addResp(resp); this.addResp(resp);
return this.notes[subTreeNoteId]; return this.notes[subTreeNoteId];
} }
addResp(resp) { addResp(resp: SubtreeResponse) {
const noteRows = resp.notes; const noteRows = resp.notes;
const branchRows = resp.branches; const branchRows = resp.branches;
const attributeRows = resp.attributes; const attributeRows = resp.attributes;
const noteIdsToSort = new Set(); const noteIdsToSort = new Set<string>();
for (const noteRow of noteRows) { for (const noteRow of noteRows) {
const {noteId} = noteRow; const {noteId} = noteRow;
@ -160,28 +173,28 @@ class Froca {
} }
} }
async reloadNotes(noteIds) { async reloadNotes(noteIds: string[]) {
if (noteIds.length === 0) { if (noteIds.length === 0) {
return; return;
} }
noteIds = Array.from(new Set(noteIds)); // make noteIds unique noteIds = Array.from(new Set(noteIds)); // make noteIds unique
const resp = await server.post('tree/load', { noteIds }); const resp = await server.post<SubtreeResponse>('tree/load', { noteIds });
this.addResp(resp); this.addResp(resp);
appContext.triggerEvent('notesReloaded', {noteIds}); appContext.triggerEvent('notesReloaded', {noteIds});
} }
async loadSearchNote(noteId) { async loadSearchNote(noteId: string) {
const note = await this.getNote(noteId); const note = await this.getNote(noteId);
if (!note || note.type !== 'search') { if (!note || note.type !== 'search') {
return; return;
} }
const {searchResultNoteIds, highlightedTokens, error} = await server.get(`search-note/${note.noteId}`); const {searchResultNoteIds, highlightedTokens, error} = await server.get<SearchNoteResponse>(`search-note/${note.noteId}`);
if (!Array.isArray(searchResultNoteIds)) { if (!Array.isArray(searchResultNoteIds)) {
throw new Error(`Search note '${note.noteId}' failed: ${searchResultNoteIds}`); throw new Error(`Search note '${note.noteId}' failed: ${searchResultNoteIds}`);
@ -193,7 +206,7 @@ class Froca {
froca.notes[note.noteId].childToBranch = {}; froca.notes[note.noteId].childToBranch = {};
} }
const branches = [...note.getParentBranches(), ...note.getChildBranches()]; const branches: FBranchRow[] = [...note.getParentBranches(), ...note.getChildBranches()];
searchResultNoteIds.forEach((resultNoteId, index) => branches.push({ searchResultNoteIds.forEach((resultNoteId, index) => branches.push({
// branchId should be repeatable since sometimes we reload some notes without rerendering the tree // branchId should be repeatable since sometimes we reload some notes without rerendering the tree
@ -217,8 +230,7 @@ class Froca {
return {error}; return {error};
} }
/** @returns {FNote[]} */ getNotesFromCache(noteIds: string[], silentNotFoundError = false): FNote[] {
getNotesFromCache(noteIds, silentNotFoundError = false) {
return noteIds.map(noteId => { return noteIds.map(noteId => {
if (!this.notes[noteId] && !silentNotFoundError) { if (!this.notes[noteId] && !silentNotFoundError) {
console.trace(`Can't find note '${noteId}'`); console.trace(`Can't find note '${noteId}'`);
@ -228,11 +240,10 @@ class Froca {
else { else {
return this.notes[noteId]; return this.notes[noteId];
} }
}).filter(note => !!note); }).filter(note => !!note) as FNote[];
} }
/** @returns {Promise<FNote[]>} */ async getNotes(noteIds: string[], silentNotFoundError = false): Promise<FNote[]> {
async getNotes(noteIds, silentNotFoundError = false) {
noteIds = Array.from(new Set(noteIds)); // make unique noteIds = Array.from(new Set(noteIds)); // make unique
const missingNoteIds = noteIds.filter(noteId => !this.notes[noteId]); const missingNoteIds = noteIds.filter(noteId => !this.notes[noteId]);
@ -246,18 +257,16 @@ class Froca {
} else { } else {
return this.notes[noteId]; return this.notes[noteId];
} }
}).filter(note => !!note); }).filter(note => !!note) as FNote[];
} }
/** @returns {Promise<boolean>} */ async noteExists(noteId: string): Promise<boolean> {
async noteExists(noteId) {
const notes = await this.getNotes([noteId], true); const notes = await this.getNotes([noteId], true);
return notes.length === 1; return notes.length === 1;
} }
/** @returns {Promise<FNote>} */ async getNote(noteId: string, silentNotFoundError = false): Promise<FNote | null> {
async getNote(noteId, silentNotFoundError = false) {
if (noteId === 'none') { if (noteId === 'none') {
console.trace(`No 'none' note.`); console.trace(`No 'none' note.`);
return null; return null;
@ -270,8 +279,7 @@ class Froca {
return (await this.getNotes([noteId], silentNotFoundError))[0]; return (await this.getNotes([noteId], silentNotFoundError))[0];
} }
/** @returns {FNote|null} */ getNoteFromCache(noteId: string) {
getNoteFromCache(noteId) {
if (!noteId) { if (!noteId) {
throw new Error("Empty noteId"); throw new Error("Empty noteId");
} }
@ -279,15 +287,13 @@ class Froca {
return this.notes[noteId]; return this.notes[noteId];
} }
/** @returns {FBranch[]} */ getBranches(branchIds: string[], silentNotFoundError = false): FBranch[] {
getBranches(branchIds, silentNotFoundError = false) {
return branchIds return branchIds
.map(branchId => this.getBranch(branchId, silentNotFoundError)) .map(branchId => this.getBranch(branchId, silentNotFoundError))
.filter(b => !!b); .filter(b => !!b) as FBranch[];
} }
/** @returns {FBranch} */ getBranch(branchId: string, silentNotFoundError = false) {
getBranch(branchId, silentNotFoundError = false) {
if (!(branchId in this.branches)) { if (!(branchId in this.branches)) {
if (!silentNotFoundError) { if (!silentNotFoundError) {
logError(`Not existing branch '${branchId}'`); logError(`Not existing branch '${branchId}'`);
@ -298,7 +304,7 @@ class Froca {
} }
} }
async getBranchId(parentNoteId, childNoteId) { async getBranchId(parentNoteId: string, childNoteId: string) {
if (childNoteId === 'root') { if (childNoteId === 'root') {
return 'none_root'; return 'none_root';
} }
@ -314,8 +320,7 @@ class Froca {
return child.parentToBranch[parentNoteId]; return child.parentToBranch[parentNoteId];
} }
/** @returns {Promise<FAttachment>} */ async getAttachment(attachmentId: string, silentNotFoundError = false) {
async getAttachment(attachmentId, silentNotFoundError = false) {
const attachment = this.attachments[attachmentId]; const attachment = this.attachments[attachmentId];
if (attachment) { if (attachment) {
return attachment; return attachment;
@ -324,9 +329,8 @@ class Froca {
// load all attachments for the given note even if one is requested, don't load one by one // load all attachments for the given note even if one is requested, don't load one by one
let attachmentRows; let attachmentRows;
try { try {
attachmentRows = await server.getWithSilentNotFound(`attachments/${attachmentId}/all`); attachmentRows = await server.getWithSilentNotFound<FAttachmentRow[]>(`attachments/${attachmentId}/all`);
} } catch (e: any) {
catch (e) {
if (silentNotFoundError) { if (silentNotFoundError) {
logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ` + e.message); logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ` + e.message);
return null; return null;
@ -344,14 +348,12 @@ class Froca {
return this.attachments[attachmentId]; return this.attachments[attachmentId];
} }
/** @returns {Promise<FAttachment[]>} */ async getAttachmentsForNote(noteId: string) {
async getAttachmentsForNote(noteId) { const attachmentRows = await server.get<FAttachmentRow[]>(`notes/${noteId}/attachments`);
const attachmentRows = await server.get(`notes/${noteId}/attachments`);
return this.processAttachmentRows(attachmentRows); return this.processAttachmentRows(attachmentRows);
} }
/** @returns {FAttachment[]} */ processAttachmentRows(attachmentRows: FAttachmentRow[]): FAttachment[] {
processAttachmentRows(attachmentRows) {
return attachmentRows.map(attachmentRow => { return attachmentRows.map(attachmentRow => {
let attachment; let attachment;
@ -367,22 +369,21 @@ class Froca {
}); });
} }
/** @returns {Promise<FBlob>} */ async getBlob(entityType: string, entityId: string) {
async getBlob(entityType, entityId) {
// I'm not sure why we're not using blobIds directly, it would save us this composite key ... // I'm not sure why we're not using blobIds directly, it would save us this composite key ...
// perhaps one benefit is that we're always requesting the latest blob, not relying on perhaps faulty/slow // perhaps one benefit is that we're always requesting the latest blob, not relying on perhaps faulty/slow
// websocket update? // websocket update?
const key = `${entityType}-${entityId}`; const key = `${entityType}-${entityId}`;
if (!this.blobPromises[key]) { if (!this.blobPromises[key]) {
this.blobPromises[key] = server.get(`${entityType}/${entityId}/blob`) this.blobPromises[key] = server.get<FBlobRow>(`${entityType}/${entityId}/blob`)
.then(row => new FBlob(row)) .then(row => new FBlob(row))
.catch(e => console.error(`Cannot get blob for ${entityType} '${entityId}'`, e)); .catch(e => console.error(`Cannot get blob for ${entityType} '${entityId}'`, e));
// we don't want to keep large payloads forever in memory, so we clean that up quite quickly // we don't want to keep large payloads forever in memory, so we clean that up quite quickly
// this cache is more meant to share the data between different components within one business transaction (e.g. loading of the note into the tab context and all the components) // this cache is more meant to share the data between different components within one business transaction (e.g. loading of the note into the tab context and all the components)
// if the blob is updated within the cache lifetime, it should be invalidated by froca_updater // if the blob is updated within the cache lifetime, it should be invalidated by froca_updater
this.blobPromises[key].then( this.blobPromises[key]?.then(
() => setTimeout(() => this.blobPromises[key] = null, 1000) () => setTimeout(() => this.blobPromises[key] = null, 1000)
); );
} }
@ -391,6 +392,6 @@ class Froca {
} }
} }
const froca = new Froca(); const froca = new FrocaImpl();
export default froca; export default froca;

@ -3,11 +3,13 @@ import froca from "./froca.js";
import utils from "./utils.js"; import utils from "./utils.js";
import options from "./options.js"; import options from "./options.js";
import noteAttributeCache from "./note_attribute_cache.js"; import noteAttributeCache from "./note_attribute_cache.js";
import FBranch from "../entities/fbranch.js"; import FBranch, { FBranchRow } from "../entities/fbranch.js";
import FAttribute from "../entities/fattribute.js"; import FAttribute, { FAttributeRow } from "../entities/fattribute.js";
import FAttachment from "../entities/fattachment.js"; import FAttachment, { FAttachmentRow } from "../entities/fattachment.js";
import FNote, { FNoteRow } from "../entities/fnote.js";
import { EntityChange } from "../../../services/entity_changes_interface.js";
async function processEntityChanges(entityChanges) { async function processEntityChanges(entityChanges: EntityChange[]) {
const loadResults = new LoadResults(entityChanges); const loadResults = new LoadResults(entityChanges);
for (const ec of entityChanges) { for (const ec of entityChanges) {
@ -23,13 +25,14 @@ async function processEntityChanges(entityChanges) {
} else if (ec.entityName === 'revisions') { } else if (ec.entityName === 'revisions') {
loadResults.addRevision(ec.entityId, ec.noteId, ec.componentId); loadResults.addRevision(ec.entityId, ec.noteId, ec.componentId);
} else if (ec.entityName === 'options') { } else if (ec.entityName === 'options') {
if (ec.entity.name === 'openNoteContexts') { const attributeEntity = ec.entity as FAttributeRow;
if (attributeEntity.name === 'openNoteContexts') {
continue; // only noise continue; // only noise
} }
options.set(ec.entity.name, ec.entity.value); options.set(attributeEntity.name, attributeEntity.value);
loadResults.addOption(ec.entity.name); loadResults.addOption(attributeEntity.name);
} else if (ec.entityName === 'attachments') { } else if (ec.entityName === 'attachments') {
processAttachment(loadResults, ec); processAttachment(loadResults, ec);
} else if (ec.entityName === 'blobs' || ec.entityName === 'etapi_tokens') { } else if (ec.entityName === 'blobs' || ec.entityName === 'etapi_tokens') {
@ -39,7 +42,7 @@ async function processEntityChanges(entityChanges) {
throw new Error(`Unknown entityName '${ec.entityName}'`); throw new Error(`Unknown entityName '${ec.entityName}'`);
} }
} }
catch (e) { catch (e: any) {
throw new Error(`Can't process entity ${JSON.stringify(ec)} with error ${e.message} ${e.stack}`); throw new Error(`Can't process entity ${JSON.stringify(ec)} with error ${e.message} ${e.stack}`);
} }
} }
@ -56,15 +59,16 @@ async function processEntityChanges(entityChanges) {
continue; continue;
} }
if (entityName === 'branches' && !(entity.parentNoteId in froca.notes)) { if (entityName === 'branches' && !((entity as FBranchRow).parentNoteId in froca.notes)) {
missingNoteIds.push(entity.parentNoteId); missingNoteIds.push((entity as FBranchRow).parentNoteId);
}
else if (entityName === 'attributes') {
let attributeEntity = entity as FAttributeRow;
if (attributeEntity.type === 'relation'
&& (attributeEntity.name === 'template' || attributeEntity.name === 'inherit')
&& !(attributeEntity.value in froca.notes)) {
missingNoteIds.push(attributeEntity.value);
} }
else if (entityName === 'attributes'
&& entity.type === 'relation'
&& (entity.name === 'template' || entity.name === 'inherit')
&& !(entity.value in froca.notes)) {
missingNoteIds.push(entity.value);
} }
} }
@ -77,12 +81,14 @@ async function processEntityChanges(entityChanges) {
noteAttributeCache.invalidate(); noteAttributeCache.invalidate();
} }
const appContext = (await import("../components/app_context.js")).default; // TODO: Remove after porting the file
// @ts-ignore
const appContext = (await import("../components/app_context.js")).default as any;
await appContext.triggerEvent('entitiesReloaded', {loadResults}); await appContext.triggerEvent('entitiesReloaded', {loadResults});
} }
} }
function processNoteChange(loadResults, ec) { function processNoteChange(loadResults: LoadResults, ec: EntityChange) {
const note = froca.notes[ec.entityId]; const note = froca.notes[ec.entityId];
if (!note) { if (!note) {
@ -102,21 +108,23 @@ function processNoteChange(loadResults, ec) {
delete froca.notes[ec.entityId]; delete froca.notes[ec.entityId];
} }
else { else {
if (note.blobId !== ec.entity.blobId) { if (note.blobId !== (ec.entity as FNoteRow).blobId) {
for (const key of Object.keys(froca.blobPromises)) { for (const key of Object.keys(froca.blobPromises)) {
if (key.includes(note.noteId)) { if (key.includes(note.noteId)) {
delete froca.blobPromises[key]; delete froca.blobPromises[key];
} }
} }
if (ec.componentId) {
loadResults.addNoteContent(note.noteId, ec.componentId); loadResults.addNoteContent(note.noteId, ec.componentId);
} }
}
note.update(ec.entity); note.update(ec.entity as FNoteRow);
} }
} }
async function processBranchChange(loadResults, ec) { async function processBranchChange(loadResults: LoadResults, ec: EntityChange) {
if (ec.isErased && ec.entityId in froca.branches) { if (ec.isErased && ec.entityId in froca.branches) {
utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`); utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`);
return; return;
@ -139,7 +147,9 @@ async function processBranchChange(loadResults, ec) {
delete parentNote.childToBranch[branch.noteId]; delete parentNote.childToBranch[branch.noteId];
} }
if (ec.componentId) {
loadResults.addBranch(ec.entityId, ec.componentId); loadResults.addBranch(ec.entityId, ec.componentId);
}
delete froca.branches[ec.entityId]; delete froca.branches[ec.entityId];
} }
@ -147,24 +157,27 @@ async function processBranchChange(loadResults, ec) {
return; return;
} }
if (ec.componentId) {
loadResults.addBranch(ec.entityId, ec.componentId); loadResults.addBranch(ec.entityId, ec.componentId);
}
const childNote = froca.notes[ec.entity.noteId]; const branchEntity = ec.entity as FBranchRow;
let parentNote = froca.notes[ec.entity.parentNoteId]; const childNote = froca.notes[branchEntity.noteId];
let parentNote: FNote | null = froca.notes[branchEntity.parentNoteId];
if (childNote && !childNote.isRoot() && !parentNote) { if (childNote && !childNote.isRoot() && !parentNote) {
// a branch cannot exist without the parent // a branch cannot exist without the parent
// a note loaded into froca has to also contain all its ancestors, // a note loaded into froca has to also contain all its ancestors,
// this problem happened, e.g., in sharing where _share was hidden and thus not loaded // this problem happened, e.g., in sharing where _share was hidden and thus not loaded
// sharing meant cloning into _share, which crashed because _share was not loaded // sharing meant cloning into _share, which crashed because _share was not loaded
parentNote = await froca.getNote(ec.entity.parentNoteId); parentNote = await froca.getNote(branchEntity.parentNoteId);
} }
if (branch) { if (branch) {
branch.update(ec.entity); branch.update(ec.entity as FBranch);
} }
else if (childNote || parentNote) { else if (childNote || parentNote) {
froca.branches[ec.entityId] = branch = new FBranch(froca, ec.entity); froca.branches[ec.entityId] = branch = new FBranch(froca, branchEntity);
} }
if (childNote) { if (childNote) {
@ -176,8 +189,8 @@ async function processBranchChange(loadResults, ec) {
} }
} }
function processNoteReordering(loadResults, ec) { function processNoteReordering(loadResults: LoadResults, ec: EntityChange) {
const parentNoteIdsToSort = new Set(); const parentNoteIdsToSort = new Set<string>();
for (const branchId in ec.positions) { for (const branchId in ec.positions) {
const branch = froca.branches[branchId]; const branch = froca.branches[branchId];
@ -197,10 +210,12 @@ function processNoteReordering(loadResults, ec) {
} }
} }
if (ec.componentId) {
loadResults.addNoteReordering(ec.entityId, ec.componentId); loadResults.addNoteReordering(ec.entityId, ec.componentId);
}
} }
function processAttributeChange(loadResults, ec) { function processAttributeChange(loadResults: LoadResults, ec: EntityChange) {
let attribute = froca.attributes[ec.entityId]; let attribute = froca.attributes[ec.entityId];
if (ec.isErased && ec.entityId in froca.attributes) { if (ec.isErased && ec.entityId in froca.attributes) {
@ -221,7 +236,9 @@ function processAttributeChange(loadResults, ec) {
targetNote.targetRelations = targetNote.targetRelations.filter(attributeId => attributeId !== attribute.attributeId); targetNote.targetRelations = targetNote.targetRelations.filter(attributeId => attributeId !== attribute.attributeId);
} }
if (ec.componentId) {
loadResults.addAttribute(ec.entityId, ec.componentId); loadResults.addAttribute(ec.entityId, ec.componentId);
}
delete froca.attributes[ec.entityId]; delete froca.attributes[ec.entityId];
} }
@ -229,15 +246,18 @@ function processAttributeChange(loadResults, ec) {
return; return;
} }
if (ec.componentId) {
loadResults.addAttribute(ec.entityId, ec.componentId); loadResults.addAttribute(ec.entityId, ec.componentId);
}
const sourceNote = froca.notes[ec.entity.noteId]; const attributeEntity = ec.entity as FAttributeRow;
const targetNote = ec.entity.type === 'relation' && froca.notes[ec.entity.value]; const sourceNote = froca.notes[attributeEntity.noteId];
const targetNote = attributeEntity.type === 'relation' && froca.notes[attributeEntity.value];
if (attribute) { if (attribute) {
attribute.update(ec.entity); attribute.update(ec.entity as FAttributeRow);
} else if (sourceNote || targetNote) { } else if (sourceNote || targetNote) {
attribute = new FAttribute(froca, ec.entity); attribute = new FAttribute(froca, ec.entity as FAttributeRow);
froca.attributes[attribute.attributeId] = attribute; froca.attributes[attribute.attributeId] = attribute;
@ -251,15 +271,16 @@ function processAttributeChange(loadResults, ec) {
} }
} }
function processAttachment(loadResults, ec) { function processAttachment(loadResults: LoadResults, ec: EntityChange) {
if (ec.isErased && ec.entityId in froca.attachments) { if (ec.isErased && ec.entityId in froca.attachments) {
utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`); utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`);
return; return;
} }
const attachment = froca.attachments[ec.entityId]; const attachment = froca.attachments[ec.entityId];
const attachmentEntity = ec.entity as FAttachmentRow;
if (ec.isErased || ec.entity?.isDeleted) { if (ec.isErased || (ec.entity as any)?.isDeleted) {
if (attachment) { if (attachment) {
const note = attachment.getNote(); const note = attachment.getNote();
@ -267,7 +288,7 @@ function processAttachment(loadResults, ec) {
note.attachments = note.attachments.filter(att => att.attachmentId !== attachment.attachmentId); note.attachments = note.attachments.filter(att => att.attachmentId !== attachment.attachmentId);
} }
loadResults.addAttachmentRow(ec.entity); loadResults.addAttachmentRow(attachmentEntity);
delete froca.attachments[ec.entityId]; delete froca.attachments[ec.entityId];
} }
@ -276,16 +297,17 @@ function processAttachment(loadResults, ec) {
} }
if (attachment) { if (attachment) {
attachment.update(ec.entity); attachment.update(ec.entity as FAttachmentRow);
} else { } else {
const note = froca.notes[ec.entity.ownerId]; const attachmentRow = ec.entity as FAttachmentRow;
const note = froca.notes[attachmentRow.ownerId];
if (note?.attachments) { if (note?.attachments) {
note.attachments.push(new FAttachment(froca, ec.entity)); note.attachments.push(new FAttachment(froca, attachmentRow));
} }
} }
loadResults.addAttachmentRow(ec.entity); loadResults.addAttachmentRow(attachmentEntity);
} }
export default { export default {

@ -1,7 +1,8 @@
import appContext from "../components/app_context.js"; import appContext from "../components/app_context.js";
import treeService from "./tree.js"; import treeService, { Node } from "./tree.js";
import dialogService from "./dialog.js"; import dialogService from "./dialog.js";
import froca from "./froca.js"; import froca from "./froca.js";
import NoteContext from "../components/note_context.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
function getHoistedNoteId() { function getHoistedNoteId() {
@ -18,11 +19,11 @@ async function unhoist() {
} }
} }
function isTopLevelNode(node) { function isTopLevelNode(node: Node) {
return isHoistedNode(node.getParent()); return isHoistedNode(node.getParent());
} }
function isHoistedNode(node) { function isHoistedNode(node: Node) {
// even though check for 'root' should not be necessary, we keep it just in case // even though check for 'root' should not be necessary, we keep it just in case
return node.data.noteId === "root" return node.data.noteId === "root"
|| node.data.noteId === getHoistedNoteId(); || node.data.noteId === getHoistedNoteId();
@ -36,10 +37,10 @@ async function isHoistedInHiddenSubtree() {
} }
const hoistedNote = await froca.getNote(hoistedNoteId); const hoistedNote = await froca.getNote(hoistedNoteId);
return hoistedNote.isHiddenCompletely(); return hoistedNote?.isHiddenCompletely();
} }
async function checkNoteAccess(notePath, noteContext) { async function checkNoteAccess(notePath: string, noteContext: NoteContext) {
const resolvedNotePath = await treeService.resolveNotePath(notePath, noteContext.hoistedNoteId); const resolvedNotePath = await treeService.resolveNotePath(notePath, noteContext.hoistedNoteId);
if (!resolvedNotePath) { if (!resolvedNotePath) {
@ -50,11 +51,15 @@ async function checkNoteAccess(notePath, noteContext) {
const hoistedNoteId = noteContext.hoistedNoteId; const hoistedNoteId = noteContext.hoistedNoteId;
if (!resolvedNotePath.includes(hoistedNoteId) && (!resolvedNotePath.includes('_hidden') || resolvedNotePath.includes('_lbBookmarks'))) { if (!resolvedNotePath.includes(hoistedNoteId) && (!resolvedNotePath.includes('_hidden') || resolvedNotePath.includes('_lbBookmarks'))) {
const requestedNote = await froca.getNote(treeService.getNoteIdFromUrl(resolvedNotePath)); const noteId = treeService.getNoteIdFromUrl(resolvedNotePath);
if (!noteId) {
return false;
}
const requestedNote = await froca.getNote(noteId);
const hoistedNote = await froca.getNote(hoistedNoteId); const hoistedNote = await froca.getNote(hoistedNoteId);
if ((!hoistedNote.hasAncestor('_hidden') || resolvedNotePath.includes('_lbBookmarks')) if ((!hoistedNote?.hasAncestor('_hidden') || resolvedNotePath.includes('_lbBookmarks'))
&& !await dialogService.confirm(t("hoisted_note.confirm_unhoisting", { requestedNote: requestedNote.title, hoistedNote: hoistedNote.title }))) { && !await dialogService.confirm(t("hoisted_note.confirm_unhoisting", { requestedNote: requestedNote?.title, hoistedNote: hoistedNote?.title }))) {
return false; return false;
} }

@ -1,5 +1,46 @@
import { EntityChange } from "../../../services/entity_changes_interface.js";
interface BranchRow {
branchId: string;
componentId: string;
}
interface AttributeRow {
attributeId: string;
componentId: string;
}
interface RevisionRow {
revisionId: string;
noteId?: string;
componentId?: string | null;
}
interface ContentNoteIdToComponentIdRow {
noteId: string;
componentId: string;
}
interface AttachmentRow {}
interface ContentNoteIdToComponentIdRow {
noteId: string;
componentId: string;
}
export default class LoadResults { export default class LoadResults {
constructor(entityChanges) { private entities: Record<string, Record<string, unknown>>;
private noteIdToComponentId: Record<string, string[]>;
private componentIdToNoteIds: Record<string, string[]>;
private branchRows: BranchRow[];
private attributeRows: AttributeRow[];
private revisionRows: RevisionRow[];
private noteReorderings: string[];
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
private optionNames: string[];
private attachmentRows: AttachmentRow[];
constructor(entityChanges: EntityChange[]) {
this.entities = {}; this.entities = {};
for (const {entityId, entityName, entity} of entityChanges) { for (const {entityId, entityName, entity} of entityChanges) {
@ -27,25 +68,27 @@ export default class LoadResults {
this.attachmentRows = []; this.attachmentRows = [];
} }
getEntityRow(entityName, entityId) { getEntityRow(entityName: string, entityId: string) {
return this.entities[entityName]?.[entityId]; return this.entities[entityName]?.[entityId];
} }
addNote(noteId, componentId) { addNote(noteId: string, componentId?: string | null) {
this.noteIdToComponentId[noteId] = this.noteIdToComponentId[noteId] || []; this.noteIdToComponentId[noteId] = this.noteIdToComponentId[noteId] || [];
if (componentId) {
if (!this.noteIdToComponentId[noteId].includes(componentId)) { if (!this.noteIdToComponentId[noteId].includes(componentId)) {
this.noteIdToComponentId[noteId].push(componentId); this.noteIdToComponentId[noteId].push(componentId);
} }
this.componentIdToNoteIds[componentId] = this.componentIdToNoteIds[componentId] || []; this.componentIdToNoteIds[componentId] = this.componentIdToNoteIds[componentId] || [];
if (!this.componentIdToNoteIds[componentId]) { if (this.componentIdToNoteIds[componentId]) {
this.componentIdToNoteIds[componentId].push(noteId); this.componentIdToNoteIds[componentId].push(noteId);
} }
} }
}
addBranch(branchId, componentId) { addBranch(branchId: string, componentId: string) {
this.branchRows.push({branchId, componentId}); this.branchRows.push({branchId, componentId});
} }
@ -55,7 +98,7 @@ export default class LoadResults {
.filter(branch => !!branch); .filter(branch => !!branch);
} }
addNoteReordering(parentNoteId, componentId) { addNoteReordering(parentNoteId: string, componentId: string) {
this.noteReorderings.push(parentNoteId); this.noteReorderings.push(parentNoteId);
} }
@ -63,7 +106,7 @@ export default class LoadResults {
return this.noteReorderings; return this.noteReorderings;
} }
addAttribute(attributeId, componentId) { addAttribute(attributeId: string, componentId: string) {
this.attributeRows.push({attributeId, componentId}); this.attributeRows.push({attributeId, componentId});
} }
@ -74,11 +117,11 @@ export default class LoadResults {
.filter(attr => !!attr); .filter(attr => !!attr);
} }
addRevision(revisionId, noteId, componentId) { addRevision(revisionId: string, noteId?: string, componentId?: string | null) {
this.revisionRows.push({revisionId, noteId, componentId}); this.revisionRows.push({revisionId, noteId, componentId});
} }
hasRevisionForNote(noteId) { hasRevisionForNote(noteId: string) {
return !!this.revisionRows.find(row => row.noteId === noteId); return !!this.revisionRows.find(row => row.noteId === noteId);
} }
@ -86,7 +129,7 @@ export default class LoadResults {
return Object.keys(this.noteIdToComponentId); return Object.keys(this.noteIdToComponentId);
} }
isNoteReloaded(noteId, componentId = null) { isNoteReloaded(noteId: string, componentId = null) {
if (!noteId) { if (!noteId) {
return false; return false;
} }
@ -95,11 +138,11 @@ export default class LoadResults {
return componentIds && componentIds.find(sId => sId !== componentId) !== undefined; return componentIds && componentIds.find(sId => sId !== componentId) !== undefined;
} }
addNoteContent(noteId, componentId) { addNoteContent(noteId: string, componentId: string) {
this.contentNoteIdToComponentId.push({noteId, componentId}); this.contentNoteIdToComponentId.push({noteId, componentId});
} }
isNoteContentReloaded(noteId, componentId) { isNoteContentReloaded(noteId: string, componentId: string) {
if (!noteId) { if (!noteId) {
return false; return false;
} }
@ -107,11 +150,11 @@ export default class LoadResults {
return this.contentNoteIdToComponentId.find(l => l.noteId === noteId && l.componentId !== componentId); return this.contentNoteIdToComponentId.find(l => l.noteId === noteId && l.componentId !== componentId);
} }
addOption(name) { addOption(name: string) {
this.optionNames.push(name); this.optionNames.push(name);
} }
isOptionReloaded(name) { isOptionReloaded(name: string) {
return this.optionNames.includes(name); return this.optionNames.includes(name);
} }
@ -119,7 +162,7 @@ export default class LoadResults {
return this.optionNames; return this.optionNames;
} }
addAttachmentRow(attachment) { addAttachmentRow(attachment: AttachmentRow) {
this.attachmentRows.push(attachment); this.attachmentRows.push(attachment);
} }

@ -1,3 +1,5 @@
import FAttribute from "../entities/fattribute.js";
/** /**
* The purpose of this class is to cache the list of attributes for notes. * The purpose of this class is to cache the list of attributes for notes.
* *
@ -6,8 +8,9 @@
* as loading the tree which uses attributes heavily. * as loading the tree which uses attributes heavily.
*/ */
class NoteAttributeCache { class NoteAttributeCache {
attributes: Record<string, FAttribute[]>;
constructor() { constructor() {
/** @property {Object.<string, BAttribute[]>} */
this.attributes = {}; this.attributes = {};
} }

@ -1,25 +1,31 @@
import utils from "./utils.js"; import utils from "./utils.js";
import server from "./server.js"; import server from "./server.js";
function checkType(type) { type ExecFunction = (command: string, cb: ((err: string, stdout: string, stderror: string) => void)) => void;
interface TmpResponse {
tmpFilePath: string;
}
function checkType(type: string) {
if (type !== 'notes' && type !== 'attachments') { if (type !== 'notes' && type !== 'attachments') {
throw new Error(`Unrecognized type '${type}', should be 'notes' or 'attachments'`); throw new Error(`Unrecognized type '${type}', should be 'notes' or 'attachments'`);
} }
} }
function getFileUrl(type, noteId) { function getFileUrl(type: string, noteId?: string) {
checkType(type); checkType(type);
return getUrlForDownload(`api/${type}/${noteId}/download`); return getUrlForDownload(`api/${type}/${noteId}/download`);
} }
function getOpenFileUrl(type, noteId) { function getOpenFileUrl(type: string, noteId: string) {
checkType(type); checkType(type);
return getUrlForDownload(`api/${type}/${noteId}/open`); return getUrlForDownload(`api/${type}/${noteId}/open`);
} }
function download(url) { function download(url: string) {
if (utils.isElectron()) { if (utils.isElectron()) {
const remote = utils.dynamicRequire('@electron/remote'); const remote = utils.dynamicRequire('@electron/remote');
@ -29,33 +35,33 @@ function download(url) {
} }
} }
function downloadFileNote(noteId) { function downloadFileNote(noteId: string) {
const url = `${getFileUrl('notes', noteId)}?${Date.now()}`; // don't use cache const url = `${getFileUrl('notes', noteId)}?${Date.now()}`; // don't use cache
download(url); download(url);
} }
function downloadAttachment(attachmentId) { function downloadAttachment(attachmentId: string) {
const url = `${getFileUrl('attachments', attachmentId)}?${Date.now()}`; // don't use cache const url = `${getFileUrl('attachments', attachmentId)}?${Date.now()}`; // don't use cache
download(url); download(url);
} }
async function openCustom(type, entityId, mime) { async function openCustom(type: string, entityId: string, mime: string) {
checkType(type); checkType(type);
if (!utils.isElectron() || utils.isMac()) { if (!utils.isElectron() || utils.isMac()) {
return; return;
} }
const resp = await server.post(`${type}/${entityId}/save-to-tmp-dir`); const resp = await server.post<TmpResponse>(`${type}/${entityId}/save-to-tmp-dir`);
let filePath = resp.tmpFilePath; let filePath = resp.tmpFilePath;
const {exec} = utils.dynamicRequire('child_process'); const exec = utils.dynamicRequire('child_process').exec as ExecFunction;
const platform = process.platform; const platform = process.platform;
if (platform === 'linux') { if (platform === 'linux') {
// we don't know which terminal is available, try in succession // we don't know which terminal is available, try in succession
const terminals = ['x-terminal-emulator', 'gnome-terminal', 'konsole', 'xterm', 'xfce4-terminal', 'mate-terminal', 'rxvt', 'terminator', 'terminology']; const terminals = ['x-terminal-emulator', 'gnome-terminal', 'konsole', 'xterm', 'xfce4-terminal', 'mate-terminal', 'rxvt', 'terminator', 'terminology'];
const openFileWithTerminal = (terminal) => { const openFileWithTerminal = (terminal: string) => {
const command = `${terminal} -e 'mimeopen -d "${filePath}"'`; const command = `${terminal} -e 'mimeopen -d "${filePath}"'`;
console.log(`Open Note custom: ${command} `); console.log(`Open Note custom: ${command} `);
exec(command, (error, stdout, stderr) => { exec(command, (error, stdout, stderr) => {
@ -68,11 +74,12 @@ async function openCustom(type, entityId, mime) {
}); });
}; };
const searchTerminal = (index) => { const searchTerminal = (index: number) => {
const terminal = terminals[index]; const terminal = terminals[index];
if (!terminal) { if (!terminal) {
console.error('Open Note custom: No terminal found!'); console.error('Open Note custom: No terminal found!');
open(getFileUrl(type, entityId), {url: true}); // TODO: Remove {url: true} if not needed.
(open as any)(getFileUrl(type, entityId), {url: true});
return; return;
} }
exec(`which ${terminal}`, (error, stdout, stderr) => { exec(`which ${terminal}`, (error, stdout, stderr) => {
@ -93,21 +100,27 @@ async function openCustom(type, entityId, mime) {
exec(command, (err, stdout, stderr) => { exec(command, (err, stdout, stderr) => {
if (err) { if (err) {
console.error("Open Note custom: ", err); console.error("Open Note custom: ", err);
open(getFileUrl(entityId), {url: true}); // TODO: This appears to be broken, since getFileUrl expects two arguments, with the first one being the type.
// Also don't know why {url: true} is passed.
(open as any)(getFileUrl(entityId), {url: true});
return; return;
} }
}); });
} else { } else {
console.log('Currently "Open Note custom" only supports linux and windows systems'); console.log('Currently "Open Note custom" only supports linux and windows systems');
open(getFileUrl(entityId), {url: true}); // TODO: This appears to be broken, since getFileUrl expects two arguments, with the first one being the type.
// Also don't know why {url: true} is passed.
(open as any)(getFileUrl(entityId), {url: true});
} }
} }
const openNoteCustom = async (noteId, mime) => await openCustom('notes', noteId, mime); const openNoteCustom =
const openAttachmentCustom = async (attachmentId, mime) => await openCustom('attachments', attachmentId, mime); async (noteId: string, mime: string) => await openCustom('notes', noteId, mime);
const openAttachmentCustom =
async (attachmentId: string, mime: string) => await openCustom('attachments', attachmentId, mime);
function downloadRevision(noteId, revisionId) { function downloadRevision(noteId: string, revisionId: string) {
const url = getUrlForDownload(`api/revisions/${revisionId}/download`); const url = getUrlForDownload(`api/revisions/${revisionId}/download`);
download(url); download(url);
@ -116,7 +129,7 @@ function downloadRevision(noteId, revisionId) {
/** /**
* @param url - should be without initial slash!!! * @param url - should be without initial slash!!!
*/ */
function getUrlForDownload(url) { function getUrlForDownload(url: string) {
if (utils.isElectron()) { if (utils.isElectron()) {
// electron needs absolute URL, so we extract current host, port, protocol // electron needs absolute URL, so we extract current host, port, protocol
return `${getHost()}/${url}`; return `${getHost()}/${url}`;
@ -127,18 +140,18 @@ function getUrlForDownload(url) {
} }
} }
function canOpenInBrowser(mime) { function canOpenInBrowser(mime: string) {
return mime === "application/pdf" return mime === "application/pdf"
|| mime.startsWith("image") || mime.startsWith("image")
|| mime.startsWith("audio") || mime.startsWith("audio")
|| mime.startsWith("video"); || mime.startsWith("video");
} }
async function openExternally(type, entityId, mime) { async function openExternally(type: string, entityId: string, mime: string) {
checkType(type); checkType(type);
if (utils.isElectron()) { if (utils.isElectron()) {
const resp = await server.post(`${type}/${entityId}/save-to-tmp-dir`); const resp = await server.post<TmpResponse>(`${type}/${entityId}/save-to-tmp-dir`);
const electron = utils.dynamicRequire('electron'); const electron = utils.dynamicRequire('electron');
const res = await electron.shell.openPath(resp.tmpFilePath); const res = await electron.shell.openPath(resp.tmpFilePath);
@ -158,15 +171,17 @@ async function openExternally(type, entityId, mime) {
} }
} }
const openNoteExternally = async (noteId, mime) => await openExternally('notes', noteId, mime); const openNoteExternally =
const openAttachmentExternally = async (attachmentId, mime) => await openExternally('attachments', attachmentId, mime); async (noteId: string, mime: string) => await openExternally('notes', noteId, mime);
const openAttachmentExternally =
async (attachmentId: string, mime: string) => await openExternally('attachments', attachmentId, mime);
function getHost() { function getHost() {
const url = new URL(window.location.href); const url = new URL(window.location.href);
return `${url.protocol}//${url.hostname}:${url.port}`; return `${url.protocol}//${url.hostname}:${url.port}`;
} }
async function openDirectory(directory) { async function openDirectory(directory: string) {
try { try {
if (utils.isElectron()) { if (utils.isElectron()) {
const electron = utils.dynamicRequire('electron'); const electron = utils.dynamicRequire('electron');
@ -177,7 +192,7 @@ async function openDirectory(directory) {
} else { } else {
console.error('Not running in an Electron environment.'); console.error('Not running in an Electron environment.');
} }
} catch (err) { } catch (err: any) {
// Handle file system errors (e.g. path does not exist or is inaccessible) // Handle file system errors (e.g. path does not exist or is inaccessible)
console.error('Error:', err.message); console.error('Error:', err.message);
} }

@ -1,61 +0,0 @@
import server from "./server.js";
class Options {
constructor() {
this.initializedPromise = server.get('options').then(data => this.load(data));
}
load(arr) {
this.arr = arr;
}
get(key) {
return this.arr[key];
}
getNames() {
return Object.keys(this.arr);
}
getJson(key) {
try {
return JSON.parse(this.arr[key]);
}
catch (e) {
return null;
}
}
getInt(key) {
return parseInt(this.arr[key]);
}
getFloat(key) {
return parseFloat(this.arr[key]);
}
is(key) {
return this.arr[key] === 'true';
}
set(key, value) {
this.arr[key] = value;
}
async save(key, value) {
this.set(key, value);
const payload = {};
payload[key] = value;
await server.put(`options`, payload);
}
async toggle(key) {
await this.save(key, (!this.is(key)).toString());
}
}
const options = new Options();
export default options;

@ -0,0 +1,79 @@
import server from "./server.js";
type OptionValue = string | number;
class Options {
initializedPromise: Promise<void>;
private arr!: Record<string, OptionValue>;
constructor() {
this.initializedPromise = server.get<Record<string, OptionValue>>('options').then(data => this.load(data));
}
load(arr: Record<string, OptionValue>) {
this.arr = arr;
}
get(key: string) {
return this.arr?.[key];
}
getNames() {
return Object.keys(this.arr || []);
}
getJson(key: string) {
const value = this.arr?.[key];
if (typeof value !== "string") {
return null;
}
try {
return JSON.parse(value);
}
catch (e) {
return null;
}
}
getInt(key: string) {
const value = this.arr?.[key];
if (typeof value !== "string") {
return null;
}
return parseInt(value);
}
getFloat(key: string) {
const value = this.arr?.[key];
if (typeof value !== "string") {
return null;
}
return parseFloat(value);
}
is(key: string) {
return this.arr[key] === 'true';
}
set(key: string, value: OptionValue) {
this.arr[key] = value;
}
async save(key: string, value: OptionValue) {
this.set(key, value);
const payload: Record<string, OptionValue> = {};
payload[key] = value;
await server.put(`options`, payload);
}
async toggle(key: string) {
await this.save(key, (!this.is(key)).toString());
}
}
const options = new Options();
export default options;

@ -1,16 +1,28 @@
function parse(value) { type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "url";
type Multiplicity = "single" | "multi";
interface DefinitionObject {
isPromoted?: boolean;
labelType?: LabelType;
multiplicity?: Multiplicity;
numberPrecision?: number;
promotedAlias?: string;
inverseRelation?: string;
}
function parse(value: string) {
const tokens = value.split(',').map(t => t.trim()); const tokens = value.split(',').map(t => t.trim());
const defObj = {}; const defObj: DefinitionObject = {};
for (const token of tokens) { for (const token of tokens) {
if (token === 'promoted') { if (token === 'promoted') {
defObj.isPromoted = true; defObj.isPromoted = true;
} }
else if (['text', 'number', 'boolean', 'date', 'datetime', 'time', 'url'].includes(token)) { else if (['text', 'number', 'boolean', 'date', 'datetime', 'time', 'url'].includes(token)) {
defObj.labelType = token; defObj.labelType = token as LabelType;
} }
else if (['single', 'multi'].includes(token)) { else if (['single', 'multi'].includes(token)) {
defObj.multiplicity = token; defObj.multiplicity = token as Multiplicity;
} }
else if (token.startsWith('precision')) { else if (token.startsWith('precision')) {
const chunks = token.split('='); const chunks = token.split('=');

@ -1,3 +1,4 @@
import FNote from "../entities/fnote.js";
import server from "./server.js"; import server from "./server.js";
function enableProtectedSession() { function enableProtectedSession() {
@ -20,7 +21,7 @@ async function touchProtectedSession() {
} }
} }
function touchProtectedSessionIfNecessary(note) { function touchProtectedSessionIfNecessary(note: FNote) {
if (note && note.isProtected && isProtectedSessionAvailable()) { if (note && note.isProtected && isProtectedSessionAvailable()) {
touchProtectedSession(); touchProtectedSession();
} }

@ -1,13 +1,39 @@
import utils from './utils.js'; import utils from './utils.js';
import ValidationError from "./validation_error.js"; import ValidationError from "./validation_error.js";
async function getHeaders(headers) { type Headers = Record<string, string | null | undefined>;
type Method = string;
interface Response {
headers: Headers;
body: unknown;
}
interface Arg extends Response {
statusCode: number;
method: Method;
url: string;
requestId: string;
}
interface RequestData {
resolve: (value: unknown) => any;
reject: (reason: unknown) => any;
silentNotFound: boolean;
}
export interface StandardResponse {
success: boolean;
}
async function getHeaders(headers?: Headers) {
const appContext = (await import('../components/app_context.js')).default; const appContext = (await import('../components/app_context.js')).default;
const activeNoteContext = appContext.tabManager ? appContext.tabManager.getActiveContext() : null; const activeNoteContext = appContext.tabManager ? appContext.tabManager.getActiveContext() : null;
// headers need to be lowercase because node.js automatically converts them to lower case // headers need to be lowercase because node.js automatically converts them to lower case
// also avoiding using underscores instead of dashes since nginx filters them out by default // also avoiding using underscores instead of dashes since nginx filters them out by default
const allHeaders = { const allHeaders: Headers = {
'trilium-component-id': glob.componentId, 'trilium-component-id': glob.componentId,
'trilium-local-now-datetime': utils.localNowDateTime(), 'trilium-local-now-datetime': utils.localNowDateTime(),
'trilium-hoisted-note-id': activeNoteContext ? activeNoteContext.hoistedNoteId : null, 'trilium-hoisted-note-id': activeNoteContext ? activeNoteContext.hoistedNoteId : null,
@ -28,31 +54,31 @@ async function getHeaders(headers) {
return allHeaders; return allHeaders;
} }
async function getWithSilentNotFound(url, componentId) { async function getWithSilentNotFound<T>(url: string, componentId?: string) {
return await call('GET', url, componentId, { silentNotFound: true }); return await call<T>('GET', url, componentId, { silentNotFound: true });
} }
async function get(url, componentId) { async function get<T>(url: string, componentId?: string) {
return await call('GET', url, componentId); return await call<T>('GET', url, componentId);
} }
async function post(url, data, componentId) { async function post<T>(url: string, data?: unknown, componentId?: string) {
return await call('POST', url, componentId, { data }); return await call<T>('POST', url, componentId, { data });
} }
async function put(url, data, componentId) { async function put<T>(url: string, data?: unknown, componentId?: string) {
return await call('PUT', url, componentId, { data }); return await call<T>('PUT', url, componentId, { data });
} }
async function patch(url, data, componentId) { async function patch<T>(url: string, data: unknown, componentId?: string) {
return await call('PATCH', url, componentId, { data }); return await call<T>('PATCH', url, componentId, { data });
} }
async function remove(url, componentId) { async function remove<T>(url: string, componentId?: string) {
return await call('DELETE', url, componentId); return await call<T>('DELETE', url, componentId);
} }
async function upload(url, fileToUpload) { async function upload(url: string, fileToUpload: File) {
const formData = new FormData(); const formData = new FormData();
formData.append('upload', fileToUpload); formData.append('upload', fileToUpload);
@ -68,11 +94,17 @@ async function upload(url, fileToUpload) {
} }
let idCounter = 1; let idCounter = 1;
const idToRequestMap = {};
const idToRequestMap: Record<string, RequestData> = {};
let maxKnownEntityChangeId = 0; let maxKnownEntityChangeId = 0;
async function call(method, url, componentId, options = {}) { interface CallOptions {
data?: unknown;
silentNotFound?: boolean;
}
async function call<T>(method: string, url: string, componentId?: string, options: CallOptions = {}) {
let resp; let resp;
const headers = await getHeaders({ const headers = await getHeaders({
@ -98,7 +130,7 @@ async function call(method, url, componentId, options = {}) {
url: `/${window.glob.baseApiUrl}${url}`, url: `/${window.glob.baseApiUrl}${url}`,
data: data data: data
}); });
}); }) as any;
} }
else { else {
resp = await ajax(url, method, data, headers, !!options.silentNotFound); resp = await ajax(url, method, data, headers, !!options.silentNotFound);
@ -110,23 +142,25 @@ async function call(method, url, componentId, options = {}) {
maxKnownEntityChangeId = Math.max(maxKnownEntityChangeId, parseInt(maxEntityChangeIdStr)); maxKnownEntityChangeId = Math.max(maxKnownEntityChangeId, parseInt(maxEntityChangeIdStr));
} }
return resp.body; return resp.body as T;
} }
function ajax(url, method, data, headers, silentNotFound) { function ajax(url: string, method: string, data: unknown, headers: Headers, silentNotFound: boolean): Promise<Response> {
return new Promise((res, rej) => { return new Promise((res, rej) => {
const options = { const options: JQueryAjaxSettings = {
url: window.glob.baseApiUrl + url, url: window.glob.baseApiUrl + url,
type: method, type: method,
headers: headers, headers: headers,
timeout: 60000, timeout: 60000,
success: (body, textStatus, jqXhr) => { success: (body, textStatus, jqXhr) => {
const respHeaders = {}; const respHeaders: Headers = {};
jqXhr.getAllResponseHeaders().trim().split(/[\r\n]+/).forEach(line => { jqXhr.getAllResponseHeaders().trim().split(/[\r\n]+/).forEach(line => {
const parts = line.split(': '); const parts = line.split(': ');
const header = parts.shift(); const header = parts.shift();
if (header) {
respHeaders[header] = parts.join(': '); respHeaders[header] = parts.join(': ');
}
}); });
res({ res({
@ -165,7 +199,7 @@ function ajax(url, method, data, headers, silentNotFound) {
if (utils.isElectron()) { if (utils.isElectron()) {
const ipc = utils.dynamicRequire('electron').ipcRenderer; const ipc = utils.dynamicRequire('electron').ipcRenderer;
ipc.on('server-response', async (event, arg) => { ipc.on('server-response', async (event: string, arg: Arg) => {
if (arg.statusCode >= 200 && arg.statusCode < 300) { if (arg.statusCode >= 200 && arg.statusCode < 300) {
handleSuccessfulResponse(arg); handleSuccessfulResponse(arg);
} }
@ -182,8 +216,8 @@ if (utils.isElectron()) {
delete idToRequestMap[arg.requestId]; delete idToRequestMap[arg.requestId];
}); });
function handleSuccessfulResponse(arg) { function handleSuccessfulResponse(arg: Arg) {
if (arg.headers['Content-Type'] === 'application/json') { if (arg.headers['Content-Type'] === 'application/json' && typeof arg.body === "string") {
arg.body = JSON.parse(arg.body); arg.body = JSON.parse(arg.body);
} }
@ -199,21 +233,23 @@ if (utils.isElectron()) {
} }
} }
async function reportError(method, url, statusCode, response) { async function reportError(method: string, url: string, statusCode: number, response: unknown) {
let message = response; let message = response;
if (typeof response === 'string') { if (typeof response === 'string') {
try { try {
response = JSON.parse(response); response = JSON.parse(response);
message = response.message; message = (response as any).message;
} }
catch (e) {} catch (e) {}
} }
const toastService = (await import("./toast.js")).default; const toastService = (await import("./toast.js")).default;
const messageStr = (typeof message === "string" ? message : JSON.stringify(message));
if ([400, 404].includes(statusCode) && response && typeof response === 'object') { if ([400, 404].includes(statusCode) && response && typeof response === 'object') {
toastService.showError(message); toastService.showError(messageStr);
throw new ValidationError({ throw new ValidationError({
requestUrl: url, requestUrl: url,
method, method,
@ -222,7 +258,7 @@ async function reportError(method, url, statusCode, response) {
}); });
} else { } else {
const title = `${statusCode} ${method} ${url}`; const title = `${statusCode} ${method} ${url}`;
toastService.showErrorTitleAndMessage(title, message); toastService.showErrorTitleAndMessage(title, messageStr);
toastService.throwError(`${title} - ${message}`); toastService.throwError(`${title} - ${message}`);
} }
} }

@ -1,5 +1,13 @@
type Callback = () => Promise<void>;
export default class SpacedUpdate { export default class SpacedUpdate {
constructor(updater, updateInterval = 1000) { private updater: Callback;
private lastUpdated: number;
private changed: boolean;
private updateInterval: number;
private changeForbidden?: boolean;
constructor(updater: Callback, updateInterval = 1000) {
this.updater = updater; this.updater = updater;
this.lastUpdated = Date.now(); this.lastUpdated = Date.now();
this.changed = false; this.changed = false;
@ -52,7 +60,7 @@ export default class SpacedUpdate {
} }
} }
async allowUpdateWithoutChange(callback) { async allowUpdateWithoutChange(callback: Callback) {
this.changeForbidden = true; this.changeForbidden = true;
try { try {

@ -1,7 +1,17 @@
import ws from "./ws.js"; import ws from "./ws.js";
import utils from "./utils.js"; import utils from "./utils.js";
function toast(options) { interface ToastOptions {
id?: string;
icon: string;
title: string;
message: string;
delay?: number;
autohide?: boolean;
closeAfter?: number;
}
function toast(options: ToastOptions) {
const $toast = $( const $toast = $(
`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true"> `<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header"> <div class="toast-header">
@ -36,7 +46,7 @@ function toast(options) {
return $toast; return $toast;
} }
function showPersistent(options) { function showPersistent(options: ToastOptions) {
let $toast = $(`#toast-${options.id}`); let $toast = $(`#toast-${options.id}`);
if ($toast.length > 0) { if ($toast.length > 0) {
@ -53,11 +63,11 @@ function showPersistent(options) {
} }
} }
function closePersistent(id) { function closePersistent(id: string) {
$(`#toast-${id}`).remove(); $(`#toast-${id}`).remove();
} }
function showMessage(message, delay = 2000) { function showMessage(message: string, delay = 2000) {
console.debug(utils.now(), "message:", message); console.debug(utils.now(), "message:", message);
toast({ toast({
@ -69,13 +79,13 @@ function showMessage(message, delay = 2000) {
}); });
} }
function showAndLogError(message, delay = 10000) { function showAndLogError(message: string, delay = 10000) {
showError(message, delay); showError(message, delay);
ws.logError(message); ws.logError(message);
} }
function showError(message, delay = 10000) { function showError(message: string, delay = 10000) {
console.log(utils.now(), "error: ", message); console.log(utils.now(), "error: ", message);
toast({ toast({
@ -87,7 +97,7 @@ function showError(message, delay = 10000) {
}); });
} }
function showErrorTitleAndMessage(title, message, delay = 10000) { function showErrorTitleAndMessage(title: string, message: string, delay = 10000) {
console.log(utils.now(), "error: ", message); console.log(utils.now(), "error: ", message);
toast({ toast({
@ -99,7 +109,7 @@ function showErrorTitleAndMessage(title, message, delay = 10000) {
}); });
} }
function throwError(message) { function throwError(message: string) {
ws.logError(message); ws.logError(message);
throw new Error(message); throw new Error(message);

@ -4,10 +4,18 @@ import froca from './froca.js';
import hoistedNoteService from '../services/hoisted_note.js'; import hoistedNoteService from '../services/hoisted_note.js';
import appContext from "../components/app_context.js"; import appContext from "../components/app_context.js";
export interface Node {
getParent(): Node;
data: {
noteId?: string;
isProtected?: boolean;
}
}
/** /**
* @returns {string|null} * @returns {string|null}
*/ */
async function resolveNotePath(notePath, hoistedNoteId = 'root') { async function resolveNotePath(notePath: string, hoistedNoteId = 'root') {
const runPath = await resolveNotePathToSegments(notePath, hoistedNoteId); const runPath = await resolveNotePathToSegments(notePath, hoistedNoteId);
return runPath ? runPath.join("/") : null; return runPath ? runPath.join("/") : null;
@ -17,10 +25,8 @@ async function resolveNotePath(notePath, hoistedNoteId = 'root') {
* Accepts notePath which might or might not be valid and returns an existing path as close to the original * Accepts notePath which might or might not be valid and returns an existing path as close to the original
* notePath as possible. Part of the path might not be valid because of note moving (which causes * notePath as possible. Part of the path might not be valid because of note moving (which causes
* path change) or other corruption, in that case, this will try to get some other valid path to the correct note. * path change) or other corruption, in that case, this will try to get some other valid path to the correct note.
*
* @returns {Promise<string[]>}
*/ */
async function resolveNotePathToSegments(notePath, hoistedNoteId = 'root', logErrors = true) { async function resolveNotePathToSegments(notePath: string, hoistedNoteId = 'root', logErrors = true) {
utils.assertArguments(notePath); utils.assertArguments(notePath);
// we might get notePath with the params suffix, remove it if present // we might get notePath with the params suffix, remove it if present
@ -103,8 +109,14 @@ async function resolveNotePathToSegments(notePath, hoistedNoteId = 'root', logEr
return effectivePathSegments; return effectivePathSegments;
} }
else { else {
const note = await froca.getNote(getNoteIdFromUrl(notePath)); const noteId = getNoteIdFromUrl(notePath);
if (!noteId) {
throw new Error(`Unable to find note with ID: ${noteId}.`);
}
const note = await froca.getNote(noteId);
if (!note) {
throw new Error(`Unable to find note: ${notePath}.`);
}
const bestNotePath = note.getBestNotePath(hoistedNoteId); const bestNotePath = note.getBestNotePath(hoistedNoteId);
if (!bestNotePath) { if (!bestNotePath) {
@ -128,11 +140,11 @@ ws.subscribeToMessages(message => {
} }
}); });
function getParentProtectedStatus(node) { function getParentProtectedStatus(node: Node) {
return hoistedNoteService.isHoistedNode(node) ? false : node.getParent().data.isProtected; return hoistedNoteService.isHoistedNode(node) ? false : node.getParent().data.isProtected;
} }
function getNoteIdFromUrl(urlOrNotePath) { function getNoteIdFromUrl(urlOrNotePath: string) {
if (!urlOrNotePath) { if (!urlOrNotePath) {
return null; return null;
} }
@ -143,13 +155,16 @@ function getNoteIdFromUrl(urlOrNotePath) {
return segments[segments.length - 1]; return segments[segments.length - 1];
} }
async function getBranchIdFromUrl(urlOrNotePath) { async function getBranchIdFromUrl(urlOrNotePath: string) {
const {noteId, parentNoteId} = getNoteIdAndParentIdFromUrl(urlOrNotePath); const {noteId, parentNoteId} = getNoteIdAndParentIdFromUrl(urlOrNotePath);
if (!parentNoteId) {
return null;
}
return await froca.getBranchId(parentNoteId, noteId); return await froca.getBranchId(parentNoteId, noteId);
} }
function getNoteIdAndParentIdFromUrl(urlOrNotePath) { function getNoteIdAndParentIdFromUrl(urlOrNotePath: string) {
if (!urlOrNotePath) { if (!urlOrNotePath) {
return {}; return {};
} }
@ -182,7 +197,7 @@ function getNoteIdAndParentIdFromUrl(urlOrNotePath) {
}; };
} }
function getNotePath(node) { function getNotePath(node: Node) {
if (!node) { if (!node) {
logError("Node is null"); logError("Node is null");
return ""; return "";
@ -201,7 +216,7 @@ function getNotePath(node) {
return path.reverse().join("/"); return path.reverse().join("/");
} }
async function getNoteTitle(noteId, parentNoteId = null) { async function getNoteTitle(noteId: string, parentNoteId: string | null = null) {
utils.assertArguments(noteId); utils.assertArguments(noteId);
const note = await froca.getNote(noteId); const note = await froca.getNote(noteId);
@ -226,7 +241,7 @@ async function getNoteTitle(noteId, parentNoteId = null) {
return title; return title;
} }
async function getNotePathTitleComponents(notePath) { async function getNotePathTitleComponents(notePath: string) {
const titleComponents = []; const titleComponents = [];
if (notePath.startsWith('root/')) { if (notePath.startsWith('root/')) {
@ -249,7 +264,7 @@ async function getNotePathTitleComponents(notePath) {
return titleComponents; return titleComponents;
} }
async function getNotePathTitle(notePath) { async function getNotePathTitle(notePath: string) {
utils.assertArguments(notePath); utils.assertArguments(notePath);
const titlePath = await getNotePathTitleComponents(notePath); const titlePath = await getNotePathTitleComponents(notePath);
@ -257,7 +272,7 @@ async function getNotePathTitle(notePath) {
return titlePath.join(' / '); return titlePath.join(' / ');
} }
async function getNoteTitleWithPathAsSuffix(notePath) { async function getNoteTitleWithPathAsSuffix(notePath: string) {
utils.assertArguments(notePath); utils.assertArguments(notePath);
const titleComponents = await getNotePathTitleComponents(notePath); const titleComponents = await getNotePathTitleComponents(notePath);
@ -278,7 +293,7 @@ async function getNoteTitleWithPathAsSuffix(notePath) {
return $titleWithPath; return $titleWithPath;
} }
function formatNotePath(path) { function formatNotePath(path: string[]) {
const $notePath = $('<span class="note-path">'); const $notePath = $('<span class="note-path">');
if (path.length > 0) { if (path.length > 0) {
@ -299,7 +314,7 @@ function formatNotePath(path) {
return $notePath; return $notePath;
} }
function isNotePathInHiddenSubtree(notePath) { function isNotePathInHiddenSubtree(notePath: string) {
return notePath?.includes("root/_hidden"); return notePath?.includes("root/_hidden");
} }

@ -1,4 +1,7 @@
function reloadFrontendApp(reason) { import dayjs from "dayjs";
import { Modal } from "bootstrap";
function reloadFrontendApp(reason?: string) {
if (reason) { if (reason) {
logInfo(`Frontend app reload: ${reason}`); logInfo(`Frontend app reload: ${reason}`);
} }
@ -6,33 +9,33 @@ function reloadFrontendApp(reason) {
window.location.reload(); window.location.reload();
} }
function parseDate(str) { function parseDate(str: string) {
try { try {
return new Date(Date.parse(str)); return new Date(Date.parse(str));
} }
catch (e) { catch (e: any) {
throw new Error(`Can't parse date from '${str}': ${e.message} ${e.stack}`); throw new Error(`Can't parse date from '${str}': ${e.message} ${e.stack}`);
} }
} }
function padNum(num) { function padNum(num: number) {
return `${num <= 9 ? "0" : ""}${num}`; return `${num <= 9 ? "0" : ""}${num}`;
} }
function formatTime(date) { function formatTime(date: Date) {
return `${padNum(date.getHours())}:${padNum(date.getMinutes())}`; return `${padNum(date.getHours())}:${padNum(date.getMinutes())}`;
} }
function formatTimeWithSeconds(date) { function formatTimeWithSeconds(date: Date) {
return `${padNum(date.getHours())}:${padNum(date.getMinutes())}:${padNum(date.getSeconds())}`; return `${padNum(date.getHours())}:${padNum(date.getMinutes())}:${padNum(date.getSeconds())}`;
} }
function formatTimeInterval(ms) { function formatTimeInterval(ms: number) {
const seconds = Math.round(ms / 1000); const seconds = Math.round(ms / 1000);
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
const plural = (count, name) => `${count} ${name}${count > 1 ? 's' : ''}`; const plural = (count: number, name: string) => `${count} ${name}${count > 1 ? 's' : ''}`;
const segments = []; const segments = [];
if (days > 0) { if (days > 0) {
@ -60,20 +63,20 @@ function formatTimeInterval(ms) {
return segments.join(", "); return segments.join(", ");
} }
// this is producing local time! /** this is producing local time! **/
function formatDate(date) { function formatDate(date: Date) {
// return padNum(date.getDate()) + ". " + padNum(date.getMonth() + 1) + ". " + date.getFullYear(); // return padNum(date.getDate()) + ". " + padNum(date.getMonth() + 1) + ". " + date.getFullYear();
// instead of european format we'll just use ISO as that's pretty unambiguous // instead of european format we'll just use ISO as that's pretty unambiguous
return formatDateISO(date); return formatDateISO(date);
} }
// this is producing local time! /** this is producing local time! **/
function formatDateISO(date) { function formatDateISO(date: Date) {
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`; return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
} }
function formatDateTime(date) { function formatDateTime(date: Date) {
return `${formatDate(date)} ${formatTime(date)}`; return `${formatDate(date)} ${formatTime(date)}`;
} }
@ -96,20 +99,20 @@ function isMac() {
return navigator.platform.indexOf('Mac') > -1; return navigator.platform.indexOf('Mac') > -1;
} }
function isCtrlKey(evt) { function isCtrlKey(evt: KeyboardEvent) {
return (!isMac() && evt.ctrlKey) return (!isMac() && evt.ctrlKey)
|| (isMac() && evt.metaKey); || (isMac() && evt.metaKey);
} }
function assertArguments() { function assertArguments(...args: string[]) {
for (const i in arguments) { for (const i in args) {
if (!arguments[i]) { if (!args[i]) {
console.trace(`Argument idx#${i} should not be falsy: ${arguments[i]}`); console.trace(`Argument idx#${i} should not be falsy: ${args[i]}`);
} }
} }
} }
const entityMap = { const entityMap: Record<string, string> = {
'&': '&amp;', '&': '&amp;',
'<': '&lt;', '<': '&lt;',
'>': '&gt;', '>': '&gt;',
@ -120,11 +123,11 @@ const entityMap = {
'=': '&#x3D;' '=': '&#x3D;'
}; };
function escapeHtml(str) { function escapeHtml(str: string) {
return str.replace(/[&<>"'`=\/]/g, s => entityMap[s]); return str.replace(/[&<>"'`=\/]/g, s => entityMap[s]);
} }
function formatSize(size) { function formatSize(size: number) {
size = Math.max(Math.round(size / 1024), 1); size = Math.max(Math.round(size / 1024), 1);
if (size < 1024) { if (size < 1024) {
@ -135,8 +138,8 @@ function formatSize(size) {
} }
} }
function toObject(array, fn) { function toObject<T>(array: T[], fn: (arg0: T) => [key: string, value: T]) {
const obj = {}; const obj: Record<string, T> = {};
for (const item of array) { for (const item of array) {
const [key, value] = fn(item); const [key, value] = fn(item);
@ -147,7 +150,7 @@ function toObject(array, fn) {
return obj; return obj;
} }
function randomString(len) { function randomString(len: number) {
let text = ""; let text = "";
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@ -170,21 +173,22 @@ function isDesktop() {
|| (!window.glob?.device && !/Mobi/.test(navigator.userAgent)); || (!window.glob?.device && !/Mobi/.test(navigator.userAgent));
} }
// the cookie code below works for simple use cases only - ASCII only /**
// not setting a path so that cookies do not leak into other websites if multiplexed with reverse proxy * the cookie code below works for simple use cases only - ASCII only
* not setting a path so that cookies do not leak into other websites if multiplexed with reverse proxy
function setCookie(name, value) { */
function setCookie(name: string, value: string) {
const date = new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000); const date = new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000);
const expires = `; expires=${date.toUTCString()}`; const expires = `; expires=${date.toUTCString()}`;
document.cookie = `${name}=${value || ""}${expires};`; document.cookie = `${name}=${value || ""}${expires};`;
} }
function getNoteTypeClass(type) { function getNoteTypeClass(type: string) {
return `type-${type}`; return `type-${type}`;
} }
function getMimeTypeClass(mime) { function getMimeTypeClass(mime: string) {
if (!mime) { if (!mime) {
return ""; return "";
} }
@ -201,12 +205,12 @@ function getMimeTypeClass(mime) {
function closeActiveDialog() { function closeActiveDialog() {
if (glob.activeDialog) { if (glob.activeDialog) {
bootstrap.Modal.getOrCreateInstance(glob.activeDialog).hide(); Modal.getOrCreateInstance(glob.activeDialog[0]).hide();
glob.activeDialog = null; glob.activeDialog = null;
} }
} }
let $lastFocusedElement = null; let $lastFocusedElement: JQuery<HTMLElement> | null;
// perhaps there should be saved focused element per tab? // perhaps there should be saved focused element per tab?
function saveFocusedElement() { function saveFocusedElement() {
@ -238,14 +242,14 @@ function focusSavedElement() {
$lastFocusedElement = null; $lastFocusedElement = null;
} }
async function openDialog($dialog, closeActDialog = true) { async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true) {
if (closeActDialog) { if (closeActDialog) {
closeActiveDialog(); closeActiveDialog();
glob.activeDialog = $dialog; glob.activeDialog = $dialog;
} }
saveFocusedElement(); saveFocusedElement();
bootstrap.Modal.getOrCreateInstance($dialog).show(); Modal.getOrCreateInstance($dialog[0]).show();
$dialog.on('hidden.bs.modal', () => { $dialog.on('hidden.bs.modal', () => {
$(".aa-input").autocomplete("close"); $(".aa-input").autocomplete("close");
@ -255,11 +259,13 @@ async function openDialog($dialog, closeActDialog = true) {
} }
}); });
// TODO: Fix once keyboard_actions is ported.
// @ts-ignore
const keyboardActionsService = (await import("./keyboard_actions.js")).default; const keyboardActionsService = (await import("./keyboard_actions.js")).default;
keyboardActionsService.updateDisplayedShortcuts($dialog); keyboardActionsService.updateDisplayedShortcuts($dialog);
} }
function isHtmlEmpty(html) { function isHtmlEmpty(html: string) {
if (!html) { if (!html) {
return true; return true;
} else if (typeof html !== 'string') { } else if (typeof html !== 'string') {
@ -283,13 +289,13 @@ async function clearBrowserCache() {
} }
function copySelectionToClipboard() { function copySelectionToClipboard() {
const text = window.getSelection().toString(); const text = window?.getSelection()?.toString();
if (navigator.clipboard) { if (text && navigator.clipboard) {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
} }
} }
function dynamicRequire(moduleName) { function dynamicRequire(moduleName: string) {
if (typeof __non_webpack_require__ !== 'undefined') { if (typeof __non_webpack_require__ !== 'undefined') {
return __non_webpack_require__(moduleName); return __non_webpack_require__(moduleName);
} }
@ -298,7 +304,7 @@ function dynamicRequire(moduleName) {
} }
} }
function timeLimit(promise, limitMs, errorMessage) { function timeLimit<T>(promise: Promise<T>, limitMs: number, errorMessage?: string) {
if (!promise || !promise.then) { // it's not actually a promise if (!promise || !promise.then) { // it's not actually a promise
return promise; return promise;
} }
@ -306,7 +312,7 @@ function timeLimit(promise, limitMs, errorMessage) {
// better stack trace if created outside of promise // better stack trace if created outside of promise
const error = new Error(errorMessage || `Process exceeded time limit ${limitMs}`); const error = new Error(errorMessage || `Process exceeded time limit ${limitMs}`);
return new Promise((res, rej) => { return new Promise<T>((res, rej) => {
let resolved = false; let resolved = false;
promise.then(result => { promise.then(result => {
@ -323,7 +329,7 @@ function timeLimit(promise, limitMs, errorMessage) {
}); });
} }
function initHelpDropdown($el) { function initHelpDropdown($el: JQuery<HTMLElement>) {
// stop inside clicks from closing the menu // stop inside clicks from closing the menu
const $dropdownMenu = $el.find('.help-dropdown .dropdown-menu'); const $dropdownMenu = $el.find('.help-dropdown .dropdown-menu');
$dropdownMenu.on('click', e => e.stopPropagation()); $dropdownMenu.on('click', e => e.stopPropagation());
@ -334,7 +340,7 @@ function initHelpDropdown($el) {
const wikiBaseUrl = "https://triliumnext.github.io/Docs/Wiki/"; const wikiBaseUrl = "https://triliumnext.github.io/Docs/Wiki/";
function openHelp($button) { function openHelp($button: JQuery<HTMLElement>) {
const helpPage = $button.attr("data-help-page"); const helpPage = $button.attr("data-help-page");
if (helpPage) { if (helpPage) {
@ -344,7 +350,7 @@ function openHelp($button) {
} }
} }
function initHelpButtons($el) { function initHelpButtons($el: JQuery<HTMLElement>) {
// for some reason, the .on(event, listener, handler) does not work here (e.g. Options -> Sync -> Help button) // for some reason, the .on(event, listener, handler) does not work here (e.g. Options -> Sync -> Help button)
// so we do it manually // so we do it manually
$el.on("click", e => { $el.on("click", e => {
@ -353,35 +359,38 @@ function initHelpButtons($el) {
}); });
} }
function filterAttributeName(name) { function filterAttributeName(name: string) {
return name.replace(/[^\p{L}\p{N}_:]/ug, ""); return name.replace(/[^\p{L}\p{N}_:]/ug, "");
} }
const ATTR_NAME_MATCHER = new RegExp("^[\\p{L}\\p{N}_:]+$", "u"); const ATTR_NAME_MATCHER = new RegExp("^[\\p{L}\\p{N}_:]+$", "u");
function isValidAttributeName(name) { function isValidAttributeName(name: string) {
return ATTR_NAME_MATCHER.test(name); return ATTR_NAME_MATCHER.test(name);
} }
function sleep(time_ms) { function sleep(time_ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(resolve, time_ms); setTimeout(resolve, time_ms);
}); });
} }
function escapeRegExp(str) { function escapeRegExp(str: string) {
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
} }
function areObjectsEqual() { function areObjectsEqual () {
let i, l, leftChain, rightChain; let i;
let l;
let leftChain: Object[];
let rightChain: Object[];
function compare2Objects(x, y) { function compare2Objects (x: unknown, y: unknown) {
let p; let p;
// remember that NaN === NaN returns false // remember that NaN === NaN returns false
// and isNaN(undefined) returns true // and isNaN(undefined) returns true
if (isNaN(x) && isNaN(y) && typeof x === 'number' && typeof y === 'number') { if (typeof x === 'number' && typeof y === 'number' && isNaN(x) && isNaN(y)) {
return true; return true;
} }
@ -416,7 +425,7 @@ function areObjectsEqual() {
return false; return false;
} }
if (x.prototype !== y.prototype) { if ((x as any).prototype !== (y as any).prototype) {
return false; return false;
} }
@ -431,7 +440,7 @@ function areObjectsEqual() {
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
return false; return false;
} }
else if (typeof y[p] !== typeof x[p]) { else if (typeof (y as any)[p] !== typeof (x as any)[p]) {
return false; return false;
} }
} }
@ -440,18 +449,18 @@ function areObjectsEqual() {
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
return false; return false;
} }
else if (typeof y[p] !== typeof x[p]) { else if (typeof (y as any)[p] !== typeof (x as any)[p]) {
return false; return false;
} }
switch (typeof (x[p])) { switch (typeof ((x as any)[p])) {
case 'object': case 'object':
case 'function': case 'function':
leftChain.push(x); leftChain.push(x);
rightChain.push(y); rightChain.push(y);
if (!compare2Objects(x[p], y[p])) { if (!compare2Objects((x as any)[p], (y as any)[p])) {
return false; return false;
} }
@ -460,7 +469,7 @@ function areObjectsEqual() {
break; break;
default: default:
if (x[p] !== y[p]) { if ((x as any)[p] !== (y as any)[p]) {
return false; return false;
} }
break; break;
@ -488,10 +497,12 @@ function areObjectsEqual() {
return true; return true;
} }
function copyHtmlToClipboard(content) { function copyHtmlToClipboard(content: string) {
function listener(e) { function listener(e: ClipboardEvent) {
if (e.clipboardData) {
e.clipboardData.setData("text/html", content); e.clipboardData.setData("text/html", content);
e.clipboardData.setData("text/plain", content); e.clipboardData.setData("text/plain", content);
}
e.preventDefault(); e.preventDefault();
} }
document.addEventListener("copy", listener); document.addEventListener("copy", listener);
@ -499,21 +510,18 @@ function copyHtmlToClipboard(content) {
document.removeEventListener("copy", listener); document.removeEventListener("copy", listener);
} }
/** // TODO: Set to FNote once the file is ported.
* @param {FNote} note function createImageSrcUrl(note: { noteId: string; title: string }) {
* @return {string}
*/
function createImageSrcUrl(note) {
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`; return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
} }
/** /**
* Given a string representation of an SVG, triggers a download of the file on the client device. * Given a string representation of an SVG, triggers a download of the file on the client device.
* *
* @param {string} nameWithoutExtension the name of the file. The .svg suffix is automatically added to it. * @param nameWithoutExtension the name of the file. The .svg suffix is automatically added to it.
* @param {string} svgContent the content of the SVG file download. * @param svgContent the content of the SVG file download.
*/ */
function downloadSvg(nameWithoutExtension, svgContent) { function downloadSvg(nameWithoutExtension: string, svgContent: string) {
const filename = `${nameWithoutExtension}.svg`; const filename = `${nameWithoutExtension}.svg`;
const element = document.createElement('a'); const element = document.createElement('a');
element.setAttribute('href', `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`); element.setAttribute('href', `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`);
@ -534,11 +542,11 @@ function downloadSvg(nameWithoutExtension, svgContent) {
* 0 if v1 is equal to v2 * 0 if v1 is equal to v2
* -1 if v1 is less than v2 * -1 if v1 is less than v2
* *
* @param {string} v1 First version string * @param v1 First version string
* @param {string} v2 Second version string * @param v2 Second version string
* @returns {number} * @returns
*/ */
function compareVersions(v1, v2) { function compareVersions(v1: string, v2: string): number {
// Remove 'v' prefix and everything after dash if present // Remove 'v' prefix and everything after dash if present
v1 = v1.replace(/^v/, '').split('-')[0]; v1 = v1.replace(/^v/, '').split('-')[0];
@ -571,11 +579,8 @@ function compareVersions(v1, v2) {
/** /**
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version. * Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
* @param {string} latestVersion
* @param {string} currentVersion
* @returns {boolean}
*/ */
function isUpdateAvailable(latestVersion, currentVersion) { function isUpdateAvailable(latestVersion: string, currentVersion: string): boolean {
return compareVersions(latestVersion, currentVersion) > 0; return compareVersions(latestVersion, currentVersion) > 0;
} }

@ -1,7 +0,0 @@
export default class ValidationError {
constructor(resp) {
for (const key in resp) {
this[key] = resp[key];
}
}
}

@ -0,0 +1,7 @@
export default class ValidationError {
constructor(resp: Record<string, string | number>) {
for (const key in resp) {
(this as any)[key] = resp[key];
}
}
}

@ -4,17 +4,20 @@ import server from "./server.js";
import options from "./options.js"; import options from "./options.js";
import frocaUpdater from "./froca_updater.js"; import frocaUpdater from "./froca_updater.js";
import appContext from "../components/app_context.js"; import appContext from "../components/app_context.js";
import { EntityChange } from '../../../services/entity_changes_interface.js';
import { t } from './i18n.js';
const messageHandlers = []; type MessageHandler = (message: any) => void;
const messageHandlers: MessageHandler[] = [];
let ws; let ws: WebSocket;
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad; let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
let lastAcceptedEntityChangeSyncId = window.glob.maxEntityChangeSyncIdAtLoad; let lastAcceptedEntityChangeSyncId = window.glob.maxEntityChangeSyncIdAtLoad;
let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad; let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
let lastPingTs; let lastPingTs: number;
let frontendUpdateDataQueue = []; let frontendUpdateDataQueue: EntityChange[] = [];
function logError(message) { function logError(message: string) {
console.error(utils.now(), message); // needs to be separate from .trace() console.error(utils.now(), message); // needs to be separate from .trace()
if (ws && ws.readyState === 1) { if (ws && ws.readyState === 1) {
@ -26,7 +29,7 @@ function logError(message) {
} }
} }
function logInfo(message) { function logInfo(message: string) {
console.log(utils.now(), message); console.log(utils.now(), message);
if (ws && ws.readyState === 1) { if (ws && ws.readyState === 1) {
@ -40,17 +43,17 @@ function logInfo(message) {
window.logError = logError; window.logError = logError;
window.logInfo = logInfo; window.logInfo = logInfo;
function subscribeToMessages(messageHandler) { function subscribeToMessages(messageHandler: MessageHandler) {
messageHandlers.push(messageHandler); messageHandlers.push(messageHandler);
} }
// used to serialize frontend update operations // used to serialize frontend update operations
let consumeQueuePromise = null; let consumeQueuePromise: Promise<void> | null = null;
// to make sure each change event is processed only once. Not clear if this is still necessary // to make sure each change event is processed only once. Not clear if this is still necessary
const processedEntityChangeIds = new Set(); const processedEntityChangeIds = new Set();
function logRows(entityChanges) { function logRows(entityChanges: EntityChange[]) {
const filteredRows = entityChanges.filter(row => const filteredRows = entityChanges.filter(row =>
!processedEntityChangeIds.has(row.id) !processedEntityChangeIds.has(row.id)
&& (row.entityName !== 'options' || row.entityId !== 'openNoteContexts')); && (row.entityName !== 'options' || row.entityId !== 'openNoteContexts'));
@ -60,7 +63,7 @@ function logRows(entityChanges) {
} }
} }
async function executeFrontendUpdate(entityChanges) { async function executeFrontendUpdate(entityChanges: EntityChange[]) {
lastPingTs = Date.now(); lastPingTs = Date.now();
if (entityChanges.length > 0) { if (entityChanges.length > 0) {
@ -71,6 +74,10 @@ async function executeFrontendUpdate(entityChanges) {
// we set lastAcceptedEntityChangeId even before frontend update processing and send ping so that backend can start sending more updates // we set lastAcceptedEntityChangeId even before frontend update processing and send ping so that backend can start sending more updates
for (const entityChange of entityChanges) { for (const entityChange of entityChanges) {
if (!entityChange.id) {
continue;
}
lastAcceptedEntityChangeId = Math.max(lastAcceptedEntityChangeId, entityChange.id); lastAcceptedEntityChangeId = Math.max(lastAcceptedEntityChangeId, entityChange.id);
if (entityChange.isSynced) { if (entityChange.isSynced) {
@ -97,7 +104,7 @@ async function executeFrontendUpdate(entityChanges) {
} }
} }
async function handleMessage(event) { async function handleMessage(event: MessageEvent<any>) {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
for (const messageHandler of messageHandlers) { for (const messageHandler of messageHandlers) {
@ -126,24 +133,32 @@ async function handleMessage(event) {
toastService.showMessage(message.message); toastService.showMessage(message.message);
} }
else if (message.type === 'execute-script') { else if (message.type === 'execute-script') {
const bundleService = (await import("../services/bundle.js")).default; // TODO: Remove after porting the file
const froca = (await import("../services/froca.js")).default; // @ts-ignore
const bundleService = (await import("../services/bundle.js")).default as any;
// TODO: Remove after porting the file
// @ts-ignore
const froca = (await import("../services/froca.js")).default as any;
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null; const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;
bundleService.getAndExecuteBundle(message.currentNoteId, originEntity, message.script, message.params); bundleService.getAndExecuteBundle(message.currentNoteId, originEntity, message.script, message.params);
} }
} }
let entityChangeIdReachedListeners = []; let entityChangeIdReachedListeners: {
desiredEntityChangeId: number;
resolvePromise: () => void;
start: number;
}[] = [];
function waitForEntityChangeId(desiredEntityChangeId) { function waitForEntityChangeId(desiredEntityChangeId: number) {
if (desiredEntityChangeId <= lastProcessedEntityChangeId) { if (desiredEntityChangeId <= lastProcessedEntityChangeId) {
return Promise.resolve(); return Promise.resolve();
} }
console.debug(`Waiting for ${desiredEntityChangeId}, last processed is ${lastProcessedEntityChangeId}, last accepted ${lastAcceptedEntityChangeId}`); console.debug(`Waiting for ${desiredEntityChangeId}, last processed is ${lastProcessedEntityChangeId}, last accepted ${lastAcceptedEntityChangeId}`);
return new Promise((res, rej) => { return new Promise<void>((res, rej) => {
entityChangeIdReachedListeners.push({ entityChangeIdReachedListeners.push({
desiredEntityChangeId: desiredEntityChangeId, desiredEntityChangeId: desiredEntityChangeId,
resolvePromise: res, resolvePromise: res,
@ -178,7 +193,7 @@ async function consumeFrontendUpdateData() {
try { try {
await utils.timeLimit(frocaUpdater.processEntityChanges(nonProcessedEntityChanges), 30000); await utils.timeLimit(frocaUpdater.processEntityChanges(nonProcessedEntityChanges), 30000);
} }
catch (e) { catch (e: any) {
logError(`Encountered error ${e.message}: ${e.stack}, reloading frontend.`); logError(`Encountered error ${e.message}: ${e.stack}, reloading frontend.`);
if (!glob.isDev && !options.is('debugModeEnabled')) { if (!glob.isDev && !options.is('debugModeEnabled')) {
@ -196,9 +211,11 @@ async function consumeFrontendUpdateData() {
for (const entityChange of nonProcessedEntityChanges) { for (const entityChange of nonProcessedEntityChanges) {
processedEntityChangeIds.add(entityChange.id); processedEntityChangeIds.add(entityChange.id);
if (entityChange.id) {
lastProcessedEntityChangeId = Math.max(lastProcessedEntityChangeId, entityChange.id); lastProcessedEntityChangeId = Math.max(lastProcessedEntityChangeId, entityChange.id);
} }
} }
}
checkEntityChangeIdListeners(); checkEntityChangeIdListeners();
} }
@ -248,3 +265,4 @@ export default {
waitForMaxKnownEntityChangeId, waitForMaxKnownEntityChangeId,
getMaxKnownEntityChangeSyncId: () => lastAcceptedEntityChangeSyncId getMaxKnownEntityChangeSyncId: () => lastAcceptedEntityChangeSyncId
}; };

@ -0,0 +1,55 @@
import FNote from "./entities/fnote";
interface ElectronProcess {
type: string;
platform: string;
}
interface CustomGlobals {
isDesktop: boolean;
isMobile: boolean;
device: "mobile" | "desktop";
getComponentsByEl: (el: unknown) => unknown;
getHeaders: Promise<Record<string, string>>;
getReferenceLinkTitle: (href: string) => Promise<string>;
getReferenceLinkTitleSync: (href: string) => string;
getActiveContextNote: FNote;
requireLibrary: (library: string) => Promise<void>;
ESLINT: { js: string[]; };
appContext: AppContext;
froca: Froca;
treeCache: Froca;
importMarkdownInline: () => Promise<unknown>;
SEARCH_HELP_TEXT: string;
activeDialog: JQuery<HTMLElement> | null;
componentId: string;
csrfToken: string;
baseApiUrl: string;
isProtectedSessionAvailable: boolean;
isDev: boolean;
isMainWindow: boolean;
maxEntityChangeIdAtLoad: number;
maxEntityChangeSyncIdAtLoad: number;
}
type RequireMethod = (moduleName: string) => any;
declare global {
interface Window {
logError(message: string);
logInfo(message: string);
process?: ElectronProcess;
glob?: CustomGlobals;
}
interface JQuery {
autocomplete: (action: "close") => void;
}
declare var logError: (message: string) => void;
declare var logInfo: (message: string) => void;
declare var glob: CustomGlobals;
declare var require: RequireMethod;
declare var __non_webpack_require__: RequireMethod | undefined;
}

@ -1,12 +1,13 @@
export default class Mutex { export default class Mutex {
private current: Promise<void>;
constructor() { constructor() {
this.current = Promise.resolve(); this.current = Promise.resolve();
} }
/** @returns {Promise} */
lock() { lock() {
let resolveFun; let resolveFun: () => void;
const subPromise = new Promise(resolve => resolveFun = () => resolve()); const subPromise = new Promise<void>(resolve => resolveFun = () => resolve());
// Caller gets a promise that resolves when the current outstanding lock resolves // Caller gets a promise that resolves when the current outstanding lock resolves
const newPromise = this.current.then(() => resolveFun); const newPromise = this.current.then(() => resolveFun);
// Don't allow the next request until the new promise is done // Don't allow the next request until the new promise is done
@ -15,7 +16,7 @@ export default class Mutex {
return newPromise; return newPromise;
}; };
async runExclusively(cb) { async runExclusively(cb: () => Promise<void>) {
const unlock = await this.lock(); const unlock = await this.lock();
try { try {

@ -1,6 +1,20 @@
// taken from the HTML source of https://boxicons.com/ // taken from the HTML source of https://boxicons.com/
const categories = [ interface Category {
name: string;
id: number;
}
interface Icon {
name: string;
slug: string;
category_id: number;
type_of_icon: "REGULAR" | "SOLID" | "LOGO";
term?: string[];
className?: string;
}
const categories: Category[] = [
{"name": "All categories", "id": 0}, {"name": "All categories", "id": 0},
{ {
"name": "Accessibility", "name": "Accessibility",
@ -132,7 +146,7 @@ const categories = [
} }
]; ];
const icons = [ const icons: Icon[] = [
{ {
"name": "child", "name": "child",
"slug": "child-regular", "slug": "child-regular",
@ -11175,7 +11189,7 @@ const icons = [
} }
]; ];
function getIconClass(icon) { function getIconClass(icon: Icon) {
if (icon.type_of_icon === 'LOGO') { if (icon.type_of_icon === 'LOGO') {
return `bxl-${icon.name}`; return `bxl-${icon.name}`;
} }

@ -20,16 +20,15 @@ async function register(app: express.Application) {
if (env.isDev()) { if (env.isDev()) {
const webpack = (await import("webpack")).default; const webpack = (await import("webpack")).default;
const webpackMiddleware = (await import("webpack-dev-middleware")).default; const webpackMiddleware = (await import("webpack-dev-middleware")).default;
const productionConfig = (await import("../../webpack.config.js")).default;
const frontendCompiler = webpack({ const frontendCompiler = webpack({
mode: "development", mode: "development",
entry: { entry: productionConfig.entry,
setup: './src/public/app/setup.js', module: productionConfig.module,
mobile: './src/public/app/mobile.js', resolve: productionConfig.resolve,
desktop: './src/public/app/desktop.js', devtool: productionConfig.devtool,
}, target: productionConfig.target
devtool: 'source-map',
target: 'electron-renderer'
}); });
app.use(`/${assetPath}/app`, webpackMiddleware(frontendCompiler)); app.use(`/${assetPath}/app`, webpackMiddleware(frontendCompiler));

@ -7,10 +7,12 @@
"strict": true, "strict": true,
"noImplicitAny": true, "noImplicitAny": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"lib": ["ES2022"], "lib": [
"ES2022"
],
"downlevelIteration": true, "downlevelIteration": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true "esModuleInterop": true,
}, },
"include": [ "include": [
"./src/**/*.js", "./src/**/*.js",
@ -19,7 +21,10 @@
"./spec/**/*.ts", "./spec/**/*.ts",
"./spec-es6/**/*.ts" "./spec-es6/**/*.ts"
], ],
"exclude": ["./node_modules/**/*"], "exclude": [
"./src/public/**/*",
"./node_modules/**/*"
],
"files": [ "files": [
"src/types.d.ts" "src/types.d.ts"
] ]

@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "NodeNext",
"declaration": false,
"sourceMap": true,
"outDir": "./build",
"strict": true,
"noImplicitAny": true,
"resolveJsonModule": true,
"lib": [
"ES2022"
],
"downlevelIteration": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowJs": true
},
"include": [
"./src/public/app/**/*",
],
"files": [
"./src/public/app/types.d.ts"
]
}

@ -15,6 +15,28 @@ export default {
path: path.resolve(rootDir, 'src/public/app-dist'), path: path.resolve(rootDir, 'src/public/app-dist'),
filename: '[name].js', filename: '[name].js',
}, },
module: {
rules: [
{
test: /\.ts$/,
use: [{
loader: 'ts-loader',
options: {
configFile: path.join(rootDir, "tsconfig.webpack.json")
}
}],
exclude: /node_modules/,
},
]
},
resolve: {
extensions: ['.ts', '.js'],
extensionAlias: {
".js": [".js", ".ts"],
".cjs": [".cjs", ".cts"],
".mjs": [".mjs", ".mts"]
}
},
devtool: 'source-map', devtool: 'source-map',
target: 'electron-renderer', target: 'electron-renderer',
}; };