diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..1557f6f04 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +**/package-lock.json linguist-generated=true \ No newline at end of file diff --git a/_check_ts_progress.sh b/_check_ts_progress.sh index c7b66ce23..424760125 100755 --- a/_check_ts_progress.sh +++ b/_check_ts_progress.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash +cd src/public +echo Summary +cloc HEAD \ + --git --md \ + --include-lang=javascript,typescript + +echo By file cloc HEAD \ --git --md \ --include-lang=javascript,typescript \ - --found=filelist.txt \ - --exclude-dir=public,libraries,views,docs - -grep -R \.js$ filelist.txt -rm filelist.txt \ No newline at end of file + --by-file \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3738d851a..990dab0c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,7 @@ "striptags": "3.2.0", "tmp": "0.2.3", "tree-kill": "1.2.2", + "ts-loader": "9.5.1", "turndown": "7.2.0", "unescape": "1.0.1", "vanilla-js-wheel-zoom": "9.0.4", @@ -113,6 +114,7 @@ "@playwright/test": "1.49.1", "@types/archiver": "6.0.3", "@types/better-sqlite3": "7.6.12", + "@types/bootstrap": "5.2.10", "@types/cheerio": "0.22.35", "@types/cls-hooked": "4.3.9", "@types/compression": "1.7.5", @@ -124,9 +126,11 @@ "@types/escape-html": "1.0.4", "@types/express": "5.0.0", "@types/express-session": "1.18.1", + "@types/fs-extra": "11.0.4", "@types/html": "1.0.4", "@types/ini": "4.1.1", "@types/jasmine": "5.1.5", + "@types/jquery": "3.5.32", "@types/jsdom": "21.1.7", "@types/mime-types": "2.1.4", "@types/multer": "1.4.12", @@ -3611,6 +3615,17 @@ "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": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.24.2.tgz", @@ -3767,6 +3782,16 @@ "@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": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -4186,13 +4211,13 @@ } }, "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==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { + "@types/jsonfile": "*", "@types/node": "*" } }, @@ -4259,6 +4284,16 @@ "dev": true, "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": { "version": "21.1.7", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", @@ -4277,6 +4312,16 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "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": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -4466,6 +4511,13 @@ "@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": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.10.tgz", @@ -5771,7 +5823,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -8142,6 +8193,17 @@ "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": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -8974,7 +9036,6 @@ "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -9957,7 +10018,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -11803,7 +11863,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -13187,7 +13246,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -14541,7 +14599,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -17116,7 +17173,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -17506,7 +17562,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -17642,6 +17697,35 @@ "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": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", diff --git a/package.json b/package.json index cbb092df9..61d85cc78 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@highlightjs/cdn-assets": "11.11.0", "@mermaid-js/layout-elk": "0.1.7", "@mind-elixir/node-menu": "1.0.3", - "@triliumnext/express-partial-content": "1.0.1", + "@triliumnext/express-partial-content": "1.0.1", "archiver": "7.0.1", "async-mutex": "0.5.0", "autocomplete.js": "0.38.1", @@ -136,6 +136,7 @@ "striptags": "3.2.0", "tmp": "0.2.3", "tree-kill": "1.2.2", + "ts-loader": "9.5.1", "turndown": "7.2.0", "unescape": "1.0.1", "vanilla-js-wheel-zoom": "9.0.4", @@ -153,6 +154,7 @@ "@playwright/test": "1.49.1", "@types/archiver": "6.0.3", "@types/better-sqlite3": "7.6.12", + "@types/bootstrap": "5.2.10", "@types/cheerio": "0.22.35", "@types/cls-hooked": "4.3.9", "@types/compression": "1.7.5", @@ -164,9 +166,11 @@ "@types/escape-html": "1.0.4", "@types/express": "5.0.0", "@types/express-session": "1.18.1", + "@types/fs-extra": "11.0.4", "@types/html": "1.0.4", "@types/ini": "4.1.1", "@types/jasmine": "5.1.5", + "@types/jquery": "3.5.32", "@types/jsdom": "21.1.7", "@types/mime-types": "2.1.4", "@types/multer": "1.4.12", diff --git a/src/public/app/components/app_context.js b/src/public/app/components/app_context.ts similarity index 81% rename from src/public/app/components/app_context.js rename to src/public/app/components/app_context.ts index 6e571d50a..6372d43ce 100644 --- a/src/public/app/components/app_context.js +++ b/src/public/app/components/app_context.ts @@ -15,8 +15,34 @@ import toast from "../services/toast.js"; import ShortcutComponent from "./shortcut_component.js"; import { t, initLocale } from "../services/i18n.js"; +interface Layout { + getRootWidget: (appContext: AppContext) => RootWidget; +} + +interface RootWidget extends Component { + render: () => JQuery; +} + +interface BeforeUploadListener extends Component { + beforeUnloadEvent(): boolean; +} + +interface TriggerData { + noteId?: string; + noteIds?: string[]; + messages?: unknown[]; + callback?: () => void; +} + class AppContext extends Component { - constructor(isMainWindow) { + + isMainWindow: boolean; + components: Component[]; + beforeUnloadListeners: WeakRef[]; + tabManager!: TabManager; + layout?: Layout; + + constructor(isMainWindow: boolean) { super(); this.isMainWindow = isMainWindow; @@ -33,7 +59,7 @@ class AppContext extends Component { await initLocale(); } - setLayout(layout) { + setLayout(layout: Layout) { this.layout = layout; } @@ -73,6 +99,10 @@ class AppContext extends Component { } renderWidgets() { + if (!this.layout) { + throw new Error("Missing layout."); + } + const rootWidget = this.layout.getRootWidget(this); const $renderedWidget = rootWidget.render(); @@ -97,15 +127,13 @@ class AppContext extends Component { this.triggerEvent('initialRenderComplete'); } - /** @returns {Promise} */ - triggerEvent(name, data = {}) { + triggerEvent(name: string, data: TriggerData = {}) { return this.handleEvent(name, data); } - /** @returns {Promise<*>} */ - triggerCommand(name, data = {}) { + triggerCommand(name: string, data: TriggerData = {}) { for (const executor of this.components) { - const fun = executor[`${name}Command`]; + const fun = (executor as any)[`${name}Command`]; if (fun) { return executor.callMethod(fun, data); @@ -119,17 +147,17 @@ class AppContext extends Component { return this.triggerEvent(name, data); } - getComponentByEl(el) { + getComponentByEl(el: HTMLElement) { return $(el).closest(".component").prop('component'); } - addBeforeUnloadListener(obj) { + addBeforeUnloadListener(obj: BeforeUploadListener) { if (typeof WeakRef !== "function") { // older browsers don't support WeakRef return; } - this.beforeUnloadListeners.push(new WeakRef(obj)); + this.beforeUnloadListeners.push(new WeakRef(obj)); } } diff --git a/src/public/app/components/component.js b/src/public/app/components/component.ts similarity index 78% rename from src/public/app/components/component.js rename to src/public/app/components/component.ts index 490b70b35..0156bdfc2 100644 --- a/src/public/app/components/component.js +++ b/src/public/app/components/component.ts @@ -12,9 +12,13 @@ import utils from '../services/utils.js'; * event / command is executed in all components - by simply awaiting the `triggerEvent()`. */ export default class Component { + componentId: string; + children: Component[]; + initialized: Promise | null; + parent?: Component; + constructor() { this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`; - /** @type Component[] */ this.children = []; this.initialized = null; } @@ -24,13 +28,12 @@ export default class Component { return this.constructor.name.replace(/[^A-Z0-9]/ig, "_"); } - setParent(parent) { - /** @type Component */ + setParent(parent: Component) { this.parent = parent; return this; } - child(...components) { + child(...components: Component[]) { for (const component of components) { component.setParent(this); @@ -40,12 +43,11 @@ export default class Component { return this; } - /** @returns {Promise} */ - handleEvent(name, data) { + handleEvent(name: string, data: unknown): Promise | null { try { const callMethodPromise = this.initialized - ? this.initialized.then(() => this.callMethod(this[`${name}Event`], data)) - : this.callMethod(this[`${name}Event`], data); + ? this.initialized.then(() => this.callMethod((this as any)[`${name}Event`], data)) + : this.callMethod((this as any)[`${name}Event`], data); const childrenPromise = this.handleEventInChildren(name, data); @@ -54,20 +56,18 @@ export default class Component { ? Promise.all([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}`); return null; } } - /** @returns {Promise} */ - triggerEvent(name, data = {}) { - return this.parent.triggerEvent(name, data); + triggerEvent(name: string, data = {}): Promise | undefined | null { + return this.parent?.triggerEvent(name, data); } - /** @returns {Promise} */ - handleEventInChildren(name, data = {}) { + handleEventInChildren(name: string, data: unknown = {}) { const promises = []; for (const child of this.children) { @@ -82,9 +82,8 @@ export default class Component { return promises.length > 0 ? Promise.all(promises) : null; } - /** @returns {Promise<*>} */ - triggerCommand(name, data = {}) { - const fun = this[`${name}Command`]; + triggerCommand(name: string, data = {}): Promise | undefined | null { + const fun = (this as any)[`${name}Command`]; if (fun) { return this.callMethod(fun, data); @@ -97,7 +96,7 @@ export default class Component { } } - callMethod(fun, data) { + callMethod(fun: (arg: unknown) => Promise, data: unknown) { if (typeof fun !== 'function') { return; } diff --git a/src/public/app/components/zoom.js b/src/public/app/components/zoom.ts similarity index 74% rename from src/public/app/components/zoom.js rename to src/public/app/components/zoom.ts index 4a028a862..8a39e3db1 100644 --- a/src/public/app/components/zoom.js +++ b/src/public/app/components/zoom.ts @@ -11,7 +11,10 @@ class ZoomComponent extends Component { if (utils.isElectron()) { options.initializedPromise.then(() => { - this.setZoomFactor(options.getFloat('zoomFactor')); + const zoomFactor = options.getFloat('zoomFactor'); + if (zoomFactor) { + this.setZoomFactor(zoomFactor); + } }); window.addEventListener("wheel", event => { @@ -22,14 +25,13 @@ class ZoomComponent extends Component { } } - setZoomFactor(zoomFactor) { - zoomFactor = parseFloat(zoomFactor); - + setZoomFactor(zoomFactor: string | number) { + const parsedZoomFactor = (typeof zoomFactor !== "number" ? parseFloat(zoomFactor) : zoomFactor); 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) { zoomFactor = Math.round(zoomFactor * 10) / 10; @@ -56,8 +58,8 @@ class ZoomComponent extends Component { zoomResetEvent() { this.setZoomFactorAndSave(1); } - - setZoomFactorAndSaveEvent({zoomFactor}) { + + setZoomFactorAndSaveEvent({ zoomFactor }: { zoomFactor: number }) { this.setZoomFactorAndSave(zoomFactor); } } diff --git a/src/public/app/entities/fattachment.js b/src/public/app/entities/fattachment.ts similarity index 52% rename from src/public/app/entities/fattachment.js rename to src/public/app/entities/fattachment.ts index e0c698e96..704a53ba7 100644 --- a/src/public/app/entities/fattachment.js +++ b/src/public/app/entities/fattachment.ts @@ -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 * being a hidden child. */ 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} */ this.froca = froca; this.update(row); } - update(row) { - /** @type {string} */ + update(row: FAttachmentRow) { this.attachmentId = row.attachmentId; - /** @type {string} */ this.ownerId = row.ownerId; - /** @type {string} */ this.role = row.role; - /** @type {string} */ this.mime = row.mime; - /** @type {string} */ this.title = row.title; - /** @type {string} */ this.dateModified = row.dateModified; - /** @type {string} */ this.utcDateModified = row.utcDateModified; - /** @type {string} */ this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince; - - /** - * optionally added to the entity - * @type {int} - */ this.contentLength = row.contentLength; this.froca.attachments[this.attachmentId] = this; } - /** @returns {FNote} */ getNote() { return this.froca.notes[this.ownerId]; } - /** @return {FBlob} */ async getBlob() { return await this.froca.getBlob('attachments', this.attachmentId); } diff --git a/src/public/app/entities/fattribute.js b/src/public/app/entities/fattribute.ts similarity index 72% rename from src/public/app/entities/fattribute.js rename to src/public/app/entities/fattribute.ts index 5e36c873f..379f0588a 100644 --- a/src/public/app/entities/fattribute.js +++ b/src/public/app/entities/fattribute.ts @@ -1,45 +1,56 @@ +import { Froca } from '../services/froca-interface.js'; import promotedAttributeDefinitionParser from '../services/promoted_attribute_definition_parser.js'; /** * 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) * and relation (representing named relationship between source and target note) */ class FAttribute { - constructor(froca, row) { - /** @type {Froca} */ + private froca: Froca; + attributeId!: string; + noteId!: string; + type!: AttributeType; + name!: string; + value!: string; + position!: number; + isInheritable!: boolean; + + constructor(froca: Froca, row: FAttributeRow) { this.froca = froca; this.update(row); } - update(row) { - /** @type {string} */ + update(row: FAttributeRow) { this.attributeId = row.attributeId; - /** @type {string} */ this.noteId = row.noteId; - /** @type {AttributeType} */ this.type = row.type; - /** @type {string} */ this.name = row.name; - /** @type {string} */ this.value = row.value; - /** @type {int} */ this.position = row.position; - /** @type {boolean} */ this.isInheritable = !!row.isInheritable; } - /** @returns {FNote} */ getNote() { return this.froca.notes[this.noteId]; } - /** @returns {Promise} */ async getTargetNote() { const targetNoteId = this.targetNoteId; @@ -70,12 +81,12 @@ class FAttribute { return promotedAttributeDefinitionParser.parse(this.value); } - isDefinitionFor(attr) { + isDefinitionFor(attr: FAttribute) { return this.type === 'label' && this.name === `${attr.type}:${attr.name}`; } - get dto() { - const dto = Object.assign({}, this); + get dto(): Omit { + const dto: any = Object.assign({}, this); delete dto.froca; return dto; diff --git a/src/public/app/entities/fblob.js b/src/public/app/entities/fblob.js deleted file mode 100644 index e335d7cb8..000000000 --- a/src/public/app/entities/fblob.js +++ /dev/null @@ -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; - } - } -} diff --git a/src/public/app/entities/fblob.ts b/src/public/app/entities/fblob.ts new file mode 100644 index 000000000..d260f7da6 --- /dev/null +++ b/src/public/app/entities/fblob.ts @@ -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; + } + } +} diff --git a/src/public/app/entities/fbranch.js b/src/public/app/entities/fbranch.ts similarity index 58% rename from src/public/app/entities/fbranch.js rename to src/public/app/entities/fbranch.ts index 3fa20934b..d497c70af 100644 --- a/src/public/app/entities/fbranch.js +++ b/src/public/app/entities/fbranch.ts @@ -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 * parents. */ class FBranch { - constructor(froca, row) { - /** @type {Froca} */ + private froca: 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.update(row); } - update(row) { + update(row: FBranchRow) { /** * primary key - * @type {string} */ this.branchId = row.branchId; - /** @type {string} */ this.noteId = row.noteId; - /** @type {string} */ this.parentNoteId = row.parentNoteId; - /** @type {int} */ this.notePosition = row.notePosition; - /** @type {string} */ this.prefix = row.prefix; - /** @type {boolean} */ this.isExpanded = !!row.isExpanded; - /** @type {boolean} */ this.fromSearchNote = !!row.fromSearchNote; } - /** @returns {FNote} */ async getNote() { return this.froca.getNote(this.noteId); } - /** @returns {FNote} */ getNoteFromCache() { return this.froca.getNoteFromCache(this.noteId); } - /** @returns {FNote} */ async getParentNote() { 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() { return this.parentNoteId === 'root'; } @@ -54,8 +68,8 @@ class FBranch { return `FBranch(branchId=${this.branchId})`; } - get pojo() { - const pojo = {...this}; + get pojo(): Omit { + const pojo = {...this} as any; delete pojo.froca; return pojo; } diff --git a/src/public/app/entities/fnote.js b/src/public/app/entities/fnote.ts similarity index 70% rename from src/public/app/entities/fnote.js rename to src/public/app/entities/fnote.ts index b33b63cfd..7c38aa9f0 100644 --- a/src/public/app/entities/fnote.js +++ b/src/public/app/entities/fnote.ts @@ -4,6 +4,9 @@ import ws from "../services/ws.js"; import froca from "../services/froca.js"; import protectedSessionHolder from "../services/protected_session_holder.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 RELATION = 'relation'; @@ -30,76 +33,91 @@ const NOTE_TYPE_ICONS = { * 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 * 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"; -/** - * @typedef {Object} NotePathRecord - * @property {boolean} isArchived - * @property {boolean} isInHoistedSubTree - * @property {boolean} isSearch - * @property {Array} notePath - * @property {boolean} isHidden - */ +interface NotePathRecord { + isArchived: boolean; + isInHoistedSubTree: boolean; + isSearch: boolean; + notePath: string[]; + isHidden: boolean; +} + +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. */ class FNote { + + private froca: Froca; + + noteId!: string; + title!: string; + isProtected!: boolean; + type!: NoteType; /** - * @param {Froca} froca - * @param {Object.} row + * content-type, e.g. "application/json" */ - constructor(froca, row) { - /** @type {Froca} */ + mime!: string; + // the main use case to keep this is to detect content change which should trigger refresh + blobId!: string; + + attributes: string[]; + targetRelations: string[]; + parents: string[]; + children: string[]; + + parentToBranch: Record; + childToBranch: Record; + attachments: FAttachment[] | null; + + // Managed by Froca. + searchResultsLoaded?: boolean; + highlightedTokens?: unknown; + + constructor(froca: Froca, row: FNoteRow) { this.froca = froca; - - /** @type {string[]} */ this.attributes = []; - - /** @type {string[]} */ this.targetRelations = []; - - /** @type {string[]} */ this.parents = []; - /** @type {string[]} */ this.children = []; - /** @type {Object.} */ this.parentToBranch = {}; - - /** @type {Object.} */ this.childToBranch = {}; - /** @type {FAttachment[]|null} */ this.attachments = null; // lazy loaded this.update(row); } - update(row) { - /** @type {string} */ + update(row: FNoteRow) { this.noteId = row.noteId; - /** @type {string} */ this.title = row.title; - /** @type {boolean} */ this.isProtected = !!row.isProtected; - /** - * See {@see NoteType} for info on values. - * @type {NoteType} - */ this.type = row.type; - /** - * content-type, e.g. "application/json" - * @type {string} - */ + this.mime = row.mime; - // the main use case to keep this is to detect content change which should trigger refresh this.blobId = row.blobId; } - addParent(parentNoteId, branchId, sort = true) { + addParent(parentNoteId: string, branchId: string, sort = true) { if (parentNoteId === 'none') { return; } @@ -115,7 +133,7 @@ class FNote { } } - addChild(childNoteId, branchId, sort = true) { + addChild(childNoteId: string, branchId: string, sort = true) { if (!(childNoteId in this.childToBranch)) { this.children.push(childNoteId); } @@ -128,16 +146,18 @@ class FNote { } sortChildren() { - const branchIdPos = {}; + const branchIdPos: Record = {}; 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]]); } - /** @returns {boolean} */ isJson() { return this.mime === "application/json"; } @@ -151,34 +171,32 @@ class FNote { async getJsonContent() { const content = await this.getContent(); + if (typeof content !== "string") { + console.log(`Unknown note content for '${this.noteId}'.`); + return null; + } + try { return JSON.parse(content); } - catch (e) { + catch (e: any) { console.log(`Cannot parse content of note '${this.noteId}': `, e.message); return null; } } - /** - * @returns {string[]} - */ getParentBranchIds() { return Object.values(this.parentToBranch); } /** - * @returns {string[]} * @deprecated use getParentBranchIds() instead */ getBranchIds() { return this.getParentBranchIds(); } - /** - * @returns {FBranch[]} - */ getParentBranches() { const branchIds = Object.values(this.parentToBranch); @@ -186,19 +204,16 @@ class FNote { } /** - * @returns {FBranch[]} * @deprecated use getParentBranches() instead */ getBranches() { return this.getParentBranches(); } - /** @returns {boolean} */ hasChildren() { return this.children.length > 0; } - /** @returns {FBranch[]} */ getChildBranches() { // don't use Object.values() to guarantee order const branchIds = this.children.map(childNoteId => this.childToBranch[childNoteId]); @@ -206,12 +221,10 @@ class FNote { return this.froca.getBranches(branchIds); } - /** @returns {string[]} */ getParentNoteIds() { return this.parents; } - /** @returns {FNote[]} */ getParentNotes() { return this.froca.getNotesFromCache(this.parents); } @@ -240,17 +253,14 @@ class FNote { return this.hasAttribute('label', 'archived'); } - /** @returns {string[]} */ getChildNoteIds() { return this.children; } - /** @returns {Promise} */ async getChildNotes() { return await this.froca.getNotes(this.children); } - /** @returns {Promise} */ async getAttachments() { if (!this.attachments) { this.attachments = await this.froca.getAttachmentsForNote(this.noteId); @@ -259,14 +269,12 @@ class FNote { return this.attachments; } - /** @returns {Promise} */ - async getAttachmentsByRole(role) { + async getAttachmentsByRole(role: string) { return (await this.getAttachments()) .filter(attachment => attachment.role === role); } - /** @returns {Promise} */ - async getAttachmentById(attachmentId) { + async getAttachmentById(attachmentId: string) { const attachments = await this.getAttachments(); return attachments.find(att => att.attachmentId === attachmentId); @@ -296,11 +304,11 @@ class FNote { } /** - * @param {string} [type] - (optional) attribute type to filter - * @param {string} [name] - (optional) attribute name to filter - * @returns {FAttribute[]} all note's attributes, including inherited ones + * @param [type] - attribute type to filter + * @param [name] - attribute name to filter + * @returns all note's attributes, including inherited ones */ - getOwnedAttributes(type, name) { + getOwnedAttributes(type?: AttributeType, name?: string) { const attrs = this.attributes .map(attributeId => this.froca.attributes[attributeId]) .filter(Boolean); // filter out nulls; @@ -309,20 +317,18 @@ class FNote { } /** - * @param {string} [type] - (optional) attribute type to filter - * @param {string} [name] - (optional) attribute name to filter - * @returns {FAttribute[]} all note's attributes, including inherited ones + * @param [type] - attribute type to filter + * @param [name] - attribute name to filter + * @returns all note's attributes, including inherited ones */ - getAttributes(type, name) { + getAttributes(type?: AttributeType, name?: string) { return this.__filterAttrs(this.__getCachedAttributes([]), type, name); } /** - * @param {string[]} path - * @return {FAttribute[]} * @private */ - __getCachedAttributes(path) { + __getCachedAttributes(path: string[]): FAttribute[] { // 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 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) * - * @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') { return [['root']]; } @@ -397,10 +403,6 @@ class FNote { return notePaths; } - /** - * @param {string} [hoistedNoteId='root'] - * @return {Array} - */ getSortedNotePathRecords(hoistedNoteId = 'root') { const isHoistedRoot = hoistedNoteId === 'root'; @@ -476,14 +478,10 @@ class FNote { return true; } - /** - * @param {FAttribute[]} attributes - * @param {AttributeType} type - * @param {string} name - * @return {FAttribute[]} + /** * @private */ - __filterAttrs(attributes, type, name) { + __filterAttrs(attributes: FAttribute[], type?: AttributeType, name?: string): FAttribute[] { this.__validateTypeName(type, name); if (!type && !name) { @@ -495,15 +493,17 @@ class FNote { } else if (name) { return attributes.filter(attr => attr.name === name); } + + return []; } - __getInheritableAttributes(path) { + __getInheritableAttributes(path: string[]) { const attrs = this.__getCachedAttributes(path); return attrs.filter(attr => attr.isInheritable); } - __validateTypeName(type, name) { + __validateTypeName(type?: string, name?: string) { if (type && type !== 'label' && type !== 'relation') { 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 - * @returns {FAttribute[]} all note's labels (attributes with type label), including inherited ones + * @param [name] - label name to filter + * @returns all note's labels (attributes with type label), including inherited ones */ - getOwnedLabels(name) { + getOwnedLabels(name: string) { return this.getOwnedAttributes(LABEL, name); } /** - * @param {string} [name] - label name to filter - * @returns {FAttribute[]} all note's labels (attributes with type label), including inherited ones + * @param [name] - label name to filter + * @returns all note's labels (attributes with type label), including inherited ones */ - getLabels(name) { + getLabels(name: string) { return this.getAttributes(LABEL, name); } @@ -536,7 +536,7 @@ class FNote { const iconClassLabels = this.getLabels('iconClass'); const workspaceIconClass = this.getWorkspaceIconClass(); - if (iconClassLabels.length > 0) { + if (iconClassLabels && iconClassLabels.length > 0) { return iconClassLabels[0].value; } else if (workspaceIconClass) { @@ -579,7 +579,7 @@ class FNote { if (!childBranches) { 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 @@ -591,102 +591,104 @@ class FNote { } /** - * @param {string} [name] - relation name to filter - * @returns {FAttribute[]} all note's relations (attributes with type relation), including inherited ones + * @param [name] - relation name to filter + * @returns all note's relations (attributes with type relation), including inherited ones */ - getOwnedRelations(name) { + getOwnedRelations(name: string) { return this.getOwnedAttributes(RELATION, name); } /** - * @param {string} [name] - relation name to filter - * @returns {FAttribute[]} all note's relations (attributes with type relation), including inherited ones + * @param [name] - relation name to filter + * @returns all note's relations (attributes with type relation), including inherited ones */ - getRelations(name) { + getRelations(name: string) { return this.getAttributes(RELATION, name); } /** - * @param {AttributeType} type - attribute type (label, relation, etc.) - * @param {string} name - attribute name - * @returns {boolean} true if note has an attribute with given type and name (including inherited) + * @param type - attribute type (label, relation, etc.) + * @param name - attribute name + * @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(); return attributes.some(attr => attr.name === name && attr.type === type); } /** - * @param {AttributeType} type - attribute type (label, relation, etc.) - * @param {string} name - attribute name - * @returns {boolean} true if note has an attribute with given type and name (including inherited) + * @param type - attribute type (label, relation, etc.) + * @param name - attribute name + * @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); } /** - * @param {AttributeType} type - attribute type (label, relation, etc.) - * @param {string} 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. + * @param type - attribute type (label, relation, etc.) + * @param name - attribute name + * @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(); return attributes.find(attr => attr.name === name && attr.type === type); } /** - * @param {AttributeType} type - attribute type (label, relation, etc.) - * @param {string} 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. + * @param type - attribute type (label, relation, etc.) + * @param name - attribute name + * @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(); return attributes.find(attr => attr.name === name && attr.type === type); } /** - * @param {AttributeType} type - attribute type (label, relation, etc.) - * @param {string} name - attribute name - * @returns {string} attribute value of the given type and name or null if no such attribute exists. + * @param type - attribute type (label, relation, etc.) + * @param name - attribute name + * @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); return attr ? attr.value : null; } /** - * @param {AttributeType} type - attribute type (label, relation, etc.) - * @param {string} name - attribute name - * @returns {string} attribute value of the given type and name or null if no such attribute exists. + * @param type - attribute type (label, relation, etc.) + * @param name - attribute name + * @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); return attr ? attr.value : null; } /** - * @param {string} name - label name - * @returns {boolean} true if label exists (excluding inherited) + * @param name - label name + * @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 - * @returns {boolean} true if label exists (including inherited) + * @param name - label name + * @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 - * @returns {boolean} true if label exists (including inherited) and does not have "false" value. + * @param name - label name + * @returns true if label exists (including inherited) and does not have "false" value. */ - isLabelTruthy(name) { + isLabelTruthy(name: string) { const label = this.getLabel(name); if (!label) { @@ -697,80 +699,79 @@ class FNote { } /** - * @param {string} name - relation name - * @returns {boolean} true if relation exists (excluding inherited) + * @param name - relation name + * @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 - * @returns {boolean} true if relation exists (including inherited) + * @param name - relation name + * @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 - * @returns {FAttribute} label if it exists, null otherwise + * @param name - label name + * @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 - * @returns {FAttribute} label if it exists, null otherwise + * @param name - label name + * @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 - * @returns {FAttribute} relation if it exists, null otherwise + * @param name - relation name + * @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 - * @returns {FAttribute} relation if it exists, null otherwise + * @param name - relation name + * @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 - * @returns {string} label value if label exists, null otherwise + * @param name - label name + * @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 - * @returns {string} label value if label exists, null otherwise + * @param name - label name + * @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 - * @returns {string} relation value if relation exists, null otherwise + * @param name - relation name + * @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 - * @returns {string} relation value if relation exists, null otherwise + * @param name - relation name + * @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 - * @returns {Promise|null} target note of the relation or null (if target is empty or note was not found) + * @param name + * @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); return targets.length > 0 ? targets[0] : null; } /** - * @param {string} [name] - relation name to filter - * @returns {Promise} + * @param [name] - relation name to filter */ - async getRelationTargets(name) { + async getRelationTargets(name: string) { const relations = this.getRelations(name); const targets = []; @@ -781,9 +782,6 @@ class FNote { return targets; } - /** - * @returns {FNote[]} - */ getNotesToInheritAttributesFrom() { const relations = [ ...this.getRelations('template'), @@ -819,7 +817,7 @@ class FNote { return promotedAttrs; } - hasAncestor(ancestorNoteId, followTemplates = false, visitedNoteIds = null) { + hasAncestor(ancestorNoteId: string, followTemplates = false, visitedNoteIds: Set | null = null) { if (this.noteId === ancestorNoteId) { return true; } @@ -861,8 +859,6 @@ class FNote { /** * Get relations which target this note - * - * @returns {FAttribute[]} */ getTargetRelations() { return this.targetRelations @@ -871,8 +867,6 @@ class FNote { /** * Get relations which target this note - * - * @returns {Promise} */ async getTargetRelationSourceNotes() { const targetRelations = this.getTargetRelations(); @@ -882,13 +876,11 @@ class FNote { /** * @deprecated use getBlob() instead - * @return {Promise} */ async getNoteComplement() { return this.getBlob(); } - /** @return {Promise} */ async getBlob() { return await this.froca.getBlob('notes', this.noteId); } @@ -897,8 +889,8 @@ class FNote { return `Note(noteId=${this.noteId}, title=${this.title})`; } - get dto() { - const dto = Object.assign({}, this); + get dto(): Omit { + const dto = Object.assign({}, this) as any; delete dto.froca; return dto; @@ -919,7 +911,7 @@ class FNote { 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() { return (this.type === "code" || this.type === "file" || this.type === 'launcher') && (this.mime.startsWith("application/javascript") @@ -927,12 +919,12 @@ class FNote { || this.mime === "text/javascript"); } - /** @returns {boolean} true if this note is HTML */ + /** @returns true if this note is HTML */ isHtml() { 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() { if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith('env=frontend'))) { return "frontend"; @@ -959,11 +951,9 @@ class FNote { if (env === "frontend") { const bundleService = (await import("../services/bundle.js")).default; return await bundleService.getAndExecuteBundle(this.noteId); - } - else if (env === "backend") { - const resp = await server.post(`script/run/${this.noteId}`); - } - else { + } else if (env === "backend") { + await server.post(`script/run/${this.noteId}`); + } else { throw new Error(`Unrecognized env type ${env} for note ${this.noteId}`); } } @@ -1002,11 +992,9 @@ class FNote { /** * Provides note's date metadata. - * - * @returns {Promise<{dateCreated: string, utcDateCreated: string, dateModified: string, utcDateModified: string}>} */ async getMetadata() { - return await server.get(`notes/${this.noteId}/metadata`); + return await server.get(`notes/${this.noteId}/metadata`); } } diff --git a/src/public/app/services/css_class_manager.js b/src/public/app/services/css_class_manager.ts similarity index 86% rename from src/public/app/services/css_class_manager.js rename to src/public/app/services/css_class_manager.ts index 01a513289..f5d2e9649 100644 --- a/src/public/app/services/css_class_manager.js +++ b/src/public/app/services/css_class_manager.ts @@ -1,6 +1,6 @@ -const registeredClasses = new Set(); +const registeredClasses = new Set(); -function createClassForColor(color) { +function createClassForColor(color: string | null) { if (!color?.trim()) { return ""; } diff --git a/src/public/app/services/date_notes.js b/src/public/app/services/date_notes.ts similarity index 55% rename from src/public/app/services/date_notes.js rename to src/public/app/services/date_notes.ts index 0c3e84e53..bab4deb05 100644 --- a/src/public/app/services/date_notes.js +++ b/src/public/app/services/date_notes.ts @@ -1,67 +1,61 @@ +import dayjs from "dayjs"; +import { FNoteRow } from "../entities/fnote.js"; import froca from "./froca.js"; import server from "./server.js"; import ws from "./ws.js"; -/** @returns {FNote} */ async function getInboxNote() { - const note = await server.get(`special-notes/inbox/${dayjs().format("YYYY-MM-DD")}`, "date-note"); + const note = await server.get(`special-notes/inbox/${dayjs().format("YYYY-MM-DD")}`, "date-note"); return await froca.getNote(note.noteId); } -/** @returns {FNote} */ async function getTodayNote() { return await getDayNote(dayjs().format("YYYY-MM-DD")); } -/** @returns {FNote} */ -async function getDayNote(date) { - const note = await server.get(`special-notes/days/${date}`, "date-note"); +async function getDayNote(date: string) { + const note = await server.get(`special-notes/days/${date}`, "date-note"); await ws.waitForMaxKnownEntityChangeId(); return await froca.getNote(note.noteId); } -/** @returns {FNote} */ -async function getWeekNote(date) { - const note = await server.get(`special-notes/weeks/${date}`, "date-note"); +async function getWeekNote(date: string) { + const note = await server.get(`special-notes/weeks/${date}`, "date-note"); await ws.waitForMaxKnownEntityChangeId(); return await froca.getNote(note.noteId); } -/** @returns {FNote} */ -async function getMonthNote(month) { - const note = await server.get(`special-notes/months/${month}`, "date-note"); +async function getMonthNote(month: string) { + const note = await server.get(`special-notes/months/${month}`, "date-note"); await ws.waitForMaxKnownEntityChangeId(); return await froca.getNote(note.noteId); } -/** @returns {FNote} */ -async function getYearNote(year) { - const note = await server.get(`special-notes/years/${year}`, "date-note"); +async function getYearNote(year: string) { + const note = await server.get(`special-notes/years/${year}`, "date-note"); await ws.waitForMaxKnownEntityChangeId(); return await froca.getNote(note.noteId); } -/** @returns {FNote} */ async function createSqlConsole() { - const note = await server.post('special-notes/sql-console'); + const note = await server.post('special-notes/sql-console'); await ws.waitForMaxKnownEntityChangeId(); return await froca.getNote(note.noteId); } -/** @returns {FNote} */ async function createSearchNote(opts = {}) { - const note = await server.post('special-notes/search-note', opts); + const note = await server.post('special-notes/search-note', opts); await ws.waitForMaxKnownEntityChangeId(); diff --git a/src/public/app/services/froca-interface.ts b/src/public/app/services/froca-interface.ts new file mode 100644 index 000000000..01dd83bc7 --- /dev/null +++ b/src/public/app/services/froca-interface.ts @@ -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; + branches: Record; + attributes: Record; + attachments: Record; + blobPromises: Record | null>; + + getBlob(entityType: string, entityId: string): Promise; + getNote(noteId: string, silentNotFoundError?: boolean): Promise; + getNoteFromCache(noteId: string): FNote; + getNotesFromCache(noteIds: string[], silentNotFoundError?: boolean): FNote[]; + getNotes(noteIds: string[], silentNotFoundError?: boolean): Promise; + + getBranch(branchId: string, silentNotFoundError?: boolean): FBranch | undefined; + getBranches(branchIds: string[], silentNotFoundError?: boolean): FBranch[]; + + getAttachmentsForNote(noteId: string): Promise; +} \ No newline at end of file diff --git a/src/public/app/services/froca.js b/src/public/app/services/froca.ts similarity index 79% rename from src/public/app/services/froca.js rename to src/public/app/services/froca.ts index 17ddeccd6..5bc1191e3 100644 --- a/src/public/app/services/froca.js +++ b/src/public/app/services/froca.ts @@ -1,10 +1,24 @@ -import FBranch from "../entities/fbranch.js"; -import FNote from "../entities/fnote.js"; -import FAttribute from "../entities/fattribute.js"; +import FBranch, { FBranchRow } from "../entities/fbranch.js"; +import FNote, { FNoteRow } from "../entities/fnote.js"; +import FAttribute, { FAttributeRow } from "../entities/fattribute.js"; import server from "./server.js"; import appContext from "../components/app_context.js"; -import FBlob from "../entities/fblob.js"; -import FAttachment from "../entities/fattachment.js"; +import FBlob, { FBlobRow } from "../entities/fblob.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. @@ -16,48 +30,47 @@ import FAttachment from "../entities/fattachment.js"; * * Backend has a similar cache called Becca */ -class Froca { +class FrocaImpl implements Froca { + initializedPromise: Promise; + + notes!: Record; + branches!: Record; + attributes!: Record; + attachments!: Record; + blobPromises!: Record | null>; + constructor() { this.initializedPromise = this.loadInitialTree(); } async loadInitialTree() { - const resp = await server.get('tree'); + const resp = await server.get('tree'); // clear the cache only directly before adding new content which is important for e.g., switching to protected session - /** @type {Object.} */ this.notes = {}; - - /** @type {Object.} */ this.branches = {}; - - /** @type {Object.} */ this.attributes = {}; - - /** @type {Object.} */ this.attachments = {}; - - /** @type {Object.>} */ this.blobPromises = {}; this.addResp(resp); } - async loadSubTree(subTreeNoteId) { - const resp = await server.get(`tree?subTreeNoteId=${subTreeNoteId}`); + async loadSubTree(subTreeNoteId: string) { + const resp = await server.get(`tree?subTreeNoteId=${subTreeNoteId}`); this.addResp(resp); return this.notes[subTreeNoteId]; } - addResp(resp) { + addResp(resp: SubtreeResponse) { const noteRows = resp.notes; const branchRows = resp.branches; const attributeRows = resp.attributes; - const noteIdsToSort = new Set(); + const noteIdsToSort = new Set(); for (const noteRow of noteRows) { const {noteId} = noteRow; @@ -160,28 +173,28 @@ class Froca { } } - async reloadNotes(noteIds) { + async reloadNotes(noteIds: string[]) { if (noteIds.length === 0) { return; } noteIds = Array.from(new Set(noteIds)); // make noteIds unique - const resp = await server.post('tree/load', { noteIds }); + const resp = await server.post('tree/load', { noteIds }); this.addResp(resp); appContext.triggerEvent('notesReloaded', {noteIds}); } - async loadSearchNote(noteId) { + async loadSearchNote(noteId: string) { const note = await this.getNote(noteId); if (!note || note.type !== 'search') { return; } - const {searchResultNoteIds, highlightedTokens, error} = await server.get(`search-note/${note.noteId}`); + const {searchResultNoteIds, highlightedTokens, error} = await server.get(`search-note/${note.noteId}`); if (!Array.isArray(searchResultNoteIds)) { throw new Error(`Search note '${note.noteId}' failed: ${searchResultNoteIds}`); @@ -193,7 +206,7 @@ class Froca { froca.notes[note.noteId].childToBranch = {}; } - const branches = [...note.getParentBranches(), ...note.getChildBranches()]; + const branches: FBranchRow[] = [...note.getParentBranches(), ...note.getChildBranches()]; searchResultNoteIds.forEach((resultNoteId, index) => branches.push({ // branchId should be repeatable since sometimes we reload some notes without rerendering the tree @@ -217,8 +230,7 @@ class Froca { return {error}; } - /** @returns {FNote[]} */ - getNotesFromCache(noteIds, silentNotFoundError = false) { + getNotesFromCache(noteIds: string[], silentNotFoundError = false): FNote[] { return noteIds.map(noteId => { if (!this.notes[noteId] && !silentNotFoundError) { console.trace(`Can't find note '${noteId}'`); @@ -228,11 +240,10 @@ class Froca { else { return this.notes[noteId]; } - }).filter(note => !!note); + }).filter(note => !!note) as FNote[]; } - /** @returns {Promise} */ - async getNotes(noteIds, silentNotFoundError = false) { + async getNotes(noteIds: string[], silentNotFoundError = false): Promise { noteIds = Array.from(new Set(noteIds)); // make unique const missingNoteIds = noteIds.filter(noteId => !this.notes[noteId]); @@ -246,18 +257,16 @@ class Froca { } else { return this.notes[noteId]; } - }).filter(note => !!note); + }).filter(note => !!note) as FNote[]; } - /** @returns {Promise} */ - async noteExists(noteId) { + async noteExists(noteId: string): Promise { const notes = await this.getNotes([noteId], true); return notes.length === 1; } - /** @returns {Promise} */ - async getNote(noteId, silentNotFoundError = false) { + async getNote(noteId: string, silentNotFoundError = false): Promise { if (noteId === 'none') { console.trace(`No 'none' note.`); return null; @@ -270,8 +279,7 @@ class Froca { return (await this.getNotes([noteId], silentNotFoundError))[0]; } - /** @returns {FNote|null} */ - getNoteFromCache(noteId) { + getNoteFromCache(noteId: string) { if (!noteId) { throw new Error("Empty noteId"); } @@ -279,15 +287,13 @@ class Froca { return this.notes[noteId]; } - /** @returns {FBranch[]} */ - getBranches(branchIds, silentNotFoundError = false) { + getBranches(branchIds: string[], silentNotFoundError = false): FBranch[] { return branchIds .map(branchId => this.getBranch(branchId, silentNotFoundError)) - .filter(b => !!b); + .filter(b => !!b) as FBranch[]; } - /** @returns {FBranch} */ - getBranch(branchId, silentNotFoundError = false) { + getBranch(branchId: string, silentNotFoundError = false) { if (!(branchId in this.branches)) { if (!silentNotFoundError) { logError(`Not existing branch '${branchId}'`); @@ -298,7 +304,7 @@ class Froca { } } - async getBranchId(parentNoteId, childNoteId) { + async getBranchId(parentNoteId: string, childNoteId: string) { if (childNoteId === 'root') { return 'none_root'; } @@ -314,8 +320,7 @@ class Froca { return child.parentToBranch[parentNoteId]; } - /** @returns {Promise} */ - async getAttachment(attachmentId, silentNotFoundError = false) { + async getAttachment(attachmentId: string, silentNotFoundError = false) { const attachment = this.attachments[attachmentId]; if (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 let attachmentRows; try { - attachmentRows = await server.getWithSilentNotFound(`attachments/${attachmentId}/all`); - } - catch (e) { + attachmentRows = await server.getWithSilentNotFound(`attachments/${attachmentId}/all`); + } catch (e: any) { if (silentNotFoundError) { logInfo(`Attachment '${attachmentId}' not found, but silentNotFoundError is enabled: ` + e.message); return null; @@ -344,14 +348,12 @@ class Froca { return this.attachments[attachmentId]; } - /** @returns {Promise} */ - async getAttachmentsForNote(noteId) { - const attachmentRows = await server.get(`notes/${noteId}/attachments`); + async getAttachmentsForNote(noteId: string) { + const attachmentRows = await server.get(`notes/${noteId}/attachments`); return this.processAttachmentRows(attachmentRows); } - /** @returns {FAttachment[]} */ - processAttachmentRows(attachmentRows) { + processAttachmentRows(attachmentRows: FAttachmentRow[]): FAttachment[] { return attachmentRows.map(attachmentRow => { let attachment; @@ -367,22 +369,21 @@ class Froca { }); } - /** @returns {Promise} */ - async getBlob(entityType, entityId) { + async getBlob(entityType: string, entityId: string) { // 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 // websocket update? const key = `${entityType}-${entityId}`; if (!this.blobPromises[key]) { - this.blobPromises[key] = server.get(`${entityType}/${entityId}/blob`) + this.blobPromises[key] = server.get(`${entityType}/${entityId}/blob`) .then(row => new FBlob(row)) .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 // 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 - this.blobPromises[key].then( + this.blobPromises[key]?.then( () => setTimeout(() => this.blobPromises[key] = null, 1000) ); } @@ -391,6 +392,6 @@ class Froca { } } -const froca = new Froca(); +const froca = new FrocaImpl(); export default froca; diff --git a/src/public/app/services/froca_updater.js b/src/public/app/services/froca_updater.ts similarity index 67% rename from src/public/app/services/froca_updater.js rename to src/public/app/services/froca_updater.ts index 54050f84f..2c850881d 100644 --- a/src/public/app/services/froca_updater.js +++ b/src/public/app/services/froca_updater.ts @@ -3,11 +3,13 @@ import froca from "./froca.js"; import utils from "./utils.js"; import options from "./options.js"; import noteAttributeCache from "./note_attribute_cache.js"; -import FBranch from "../entities/fbranch.js"; -import FAttribute from "../entities/fattribute.js"; -import FAttachment from "../entities/fattachment.js"; +import FBranch, { FBranchRow } from "../entities/fbranch.js"; +import FAttribute, { FAttributeRow } from "../entities/fattribute.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); for (const ec of entityChanges) { @@ -23,13 +25,14 @@ async function processEntityChanges(entityChanges) { } else if (ec.entityName === 'revisions') { loadResults.addRevision(ec.entityId, ec.noteId, ec.componentId); } else if (ec.entityName === 'options') { - if (ec.entity.name === 'openNoteContexts') { + const attributeEntity = ec.entity as FAttributeRow; + if (attributeEntity.name === 'openNoteContexts') { 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') { processAttachment(loadResults, ec); } else if (ec.entityName === 'blobs' || ec.entityName === 'etapi_tokens') { @@ -39,7 +42,7 @@ async function processEntityChanges(entityChanges) { 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}`); } } @@ -56,15 +59,16 @@ async function processEntityChanges(entityChanges) { continue; } - if (entityName === 'branches' && !(entity.parentNoteId in froca.notes)) { - missingNoteIds.push(entity.parentNoteId); + if (entityName === 'branches' && !((entity as FBranchRow).parentNoteId in froca.notes)) { + missingNoteIds.push((entity as FBranchRow).parentNoteId); } - else if (entityName === 'attributes' - && entity.type === 'relation' - && (entity.name === 'template' || entity.name === 'inherit') - && !(entity.value in froca.notes)) { - - missingNoteIds.push(entity.value); + 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); + } } } @@ -77,12 +81,14 @@ async function processEntityChanges(entityChanges) { 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}); } } -function processNoteChange(loadResults, ec) { +function processNoteChange(loadResults: LoadResults, ec: EntityChange) { const note = froca.notes[ec.entityId]; if (!note) { @@ -102,21 +108,23 @@ function processNoteChange(loadResults, ec) { delete froca.notes[ec.entityId]; } else { - if (note.blobId !== ec.entity.blobId) { + if (note.blobId !== (ec.entity as FNoteRow).blobId) { for (const key of Object.keys(froca.blobPromises)) { if (key.includes(note.noteId)) { delete froca.blobPromises[key]; } } - loadResults.addNoteContent(note.noteId, ec.componentId); + if (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) { utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`); return; @@ -139,7 +147,9 @@ async function processBranchChange(loadResults, ec) { delete parentNote.childToBranch[branch.noteId]; } - loadResults.addBranch(ec.entityId, ec.componentId); + if (ec.componentId) { + loadResults.addBranch(ec.entityId, ec.componentId); + } delete froca.branches[ec.entityId]; } @@ -147,24 +157,27 @@ async function processBranchChange(loadResults, ec) { return; } - loadResults.addBranch(ec.entityId, ec.componentId); + if (ec.componentId) { + loadResults.addBranch(ec.entityId, ec.componentId); + } - const childNote = froca.notes[ec.entity.noteId]; - let parentNote = froca.notes[ec.entity.parentNoteId]; + const branchEntity = ec.entity as FBranchRow; + const childNote = froca.notes[branchEntity.noteId]; + let parentNote: FNote | null = froca.notes[branchEntity.parentNoteId]; if (childNote && !childNote.isRoot() && !parentNote) { // a branch cannot exist without the parent // 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 // 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) { - branch.update(ec.entity); + branch.update(ec.entity as FBranch); } 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) { @@ -176,8 +189,8 @@ async function processBranchChange(loadResults, ec) { } } -function processNoteReordering(loadResults, ec) { - const parentNoteIdsToSort = new Set(); +function processNoteReordering(loadResults: LoadResults, ec: EntityChange) { + const parentNoteIdsToSort = new Set(); for (const branchId in ec.positions) { const branch = froca.branches[branchId]; @@ -197,10 +210,12 @@ function processNoteReordering(loadResults, ec) { } } - loadResults.addNoteReordering(ec.entityId, ec.componentId); + if (ec.componentId) { + loadResults.addNoteReordering(ec.entityId, ec.componentId); + } } -function processAttributeChange(loadResults, ec) { +function processAttributeChange(loadResults: LoadResults, ec: EntityChange) { let attribute = froca.attributes[ec.entityId]; 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); } - loadResults.addAttribute(ec.entityId, ec.componentId); + if (ec.componentId) { + loadResults.addAttribute(ec.entityId, ec.componentId); + } delete froca.attributes[ec.entityId]; } @@ -229,15 +246,18 @@ function processAttributeChange(loadResults, ec) { return; } - loadResults.addAttribute(ec.entityId, ec.componentId); + if (ec.componentId) { + loadResults.addAttribute(ec.entityId, ec.componentId); + } - const sourceNote = froca.notes[ec.entity.noteId]; - const targetNote = ec.entity.type === 'relation' && froca.notes[ec.entity.value]; + const attributeEntity = ec.entity as FAttributeRow; + const sourceNote = froca.notes[attributeEntity.noteId]; + const targetNote = attributeEntity.type === 'relation' && froca.notes[attributeEntity.value]; if (attribute) { - attribute.update(ec.entity); + attribute.update(ec.entity as FAttributeRow); } else if (sourceNote || targetNote) { - attribute = new FAttribute(froca, ec.entity); + attribute = new FAttribute(froca, ec.entity as FAttributeRow); 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) { utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`); return; } 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) { const note = attachment.getNote(); @@ -267,7 +288,7 @@ function processAttachment(loadResults, ec) { note.attachments = note.attachments.filter(att => att.attachmentId !== attachment.attachmentId); } - loadResults.addAttachmentRow(ec.entity); + loadResults.addAttachmentRow(attachmentEntity); delete froca.attachments[ec.entityId]; } @@ -276,16 +297,17 @@ function processAttachment(loadResults, ec) { } if (attachment) { - attachment.update(ec.entity); + attachment.update(ec.entity as FAttachmentRow); } else { - const note = froca.notes[ec.entity.ownerId]; + const attachmentRow = ec.entity as FAttachmentRow; + const note = froca.notes[attachmentRow.ownerId]; 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 { diff --git a/src/public/app/services/hoisted_note.js b/src/public/app/services/hoisted_note.ts similarity index 72% rename from src/public/app/services/hoisted_note.js rename to src/public/app/services/hoisted_note.ts index 23d705db8..86097da78 100644 --- a/src/public/app/services/hoisted_note.js +++ b/src/public/app/services/hoisted_note.ts @@ -1,7 +1,8 @@ import appContext from "../components/app_context.js"; -import treeService from "./tree.js"; +import treeService, { Node } from "./tree.js"; import dialogService from "./dialog.js"; import froca from "./froca.js"; +import NoteContext from "../components/note_context.js"; import { t } from "./i18n.js"; function getHoistedNoteId() { @@ -18,11 +19,11 @@ async function unhoist() { } } -function isTopLevelNode(node) { +function isTopLevelNode(node: Node) { 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 return node.data.noteId === "root" || node.data.noteId === getHoistedNoteId(); @@ -36,10 +37,10 @@ async function isHoistedInHiddenSubtree() { } 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); if (!resolvedNotePath) { @@ -50,11 +51,15 @@ async function checkNoteAccess(notePath, noteContext) { const hoistedNoteId = noteContext.hoistedNoteId; 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); - if ((!hoistedNote.hasAncestor('_hidden') || resolvedNotePath.includes('_lbBookmarks')) - && !await dialogService.confirm(t("hoisted_note.confirm_unhoisting", { requestedNote: requestedNote.title, hoistedNote: hoistedNote.title }))) { + if ((!hoistedNote?.hasAncestor('_hidden') || resolvedNotePath.includes('_lbBookmarks')) + && !await dialogService.confirm(t("hoisted_note.confirm_unhoisting", { requestedNote: requestedNote?.title, hoistedNote: hoistedNote?.title }))) { return false; } diff --git a/src/public/app/services/load_results.js b/src/public/app/services/load_results.ts similarity index 61% rename from src/public/app/services/load_results.js rename to src/public/app/services/load_results.ts index 22f6bca94..7945a943e 100644 --- a/src/public/app/services/load_results.js +++ b/src/public/app/services/load_results.ts @@ -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 { - constructor(entityChanges) { + private entities: Record>; + private noteIdToComponentId: Record; + private componentIdToNoteIds: Record; + 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 = {}; for (const {entityId, entityName, entity} of entityChanges) { @@ -27,25 +68,27 @@ export default class LoadResults { this.attachmentRows = []; } - getEntityRow(entityName, entityId) { + getEntityRow(entityName: string, entityId: string) { return this.entities[entityName]?.[entityId]; } - addNote(noteId, componentId) { + addNote(noteId: string, componentId?: string | null) { this.noteIdToComponentId[noteId] = this.noteIdToComponentId[noteId] || []; - if (!this.noteIdToComponentId[noteId].includes(componentId)) { - this.noteIdToComponentId[noteId].push(componentId); - } - - this.componentIdToNoteIds[componentId] = this.componentIdToNoteIds[componentId] || []; - - if (!this.componentIdToNoteIds[componentId]) { - this.componentIdToNoteIds[componentId].push(noteId); + if (componentId) { + if (!this.noteIdToComponentId[noteId].includes(componentId)) { + this.noteIdToComponentId[noteId].push(componentId); + } + + this.componentIdToNoteIds[componentId] = this.componentIdToNoteIds[componentId] || []; + + if (this.componentIdToNoteIds[componentId]) { + this.componentIdToNoteIds[componentId].push(noteId); + } } } - addBranch(branchId, componentId) { + addBranch(branchId: string, componentId: string) { this.branchRows.push({branchId, componentId}); } @@ -55,7 +98,7 @@ export default class LoadResults { .filter(branch => !!branch); } - addNoteReordering(parentNoteId, componentId) { + addNoteReordering(parentNoteId: string, componentId: string) { this.noteReorderings.push(parentNoteId); } @@ -63,7 +106,7 @@ export default class LoadResults { return this.noteReorderings; } - addAttribute(attributeId, componentId) { + addAttribute(attributeId: string, componentId: string) { this.attributeRows.push({attributeId, componentId}); } @@ -74,11 +117,11 @@ export default class LoadResults { .filter(attr => !!attr); } - addRevision(revisionId, noteId, componentId) { + addRevision(revisionId: string, noteId?: string, componentId?: string | null) { this.revisionRows.push({revisionId, noteId, componentId}); } - hasRevisionForNote(noteId) { + hasRevisionForNote(noteId: string) { return !!this.revisionRows.find(row => row.noteId === noteId); } @@ -86,7 +129,7 @@ export default class LoadResults { return Object.keys(this.noteIdToComponentId); } - isNoteReloaded(noteId, componentId = null) { + isNoteReloaded(noteId: string, componentId = null) { if (!noteId) { return false; } @@ -95,11 +138,11 @@ export default class LoadResults { return componentIds && componentIds.find(sId => sId !== componentId) !== undefined; } - addNoteContent(noteId, componentId) { + addNoteContent(noteId: string, componentId: string) { this.contentNoteIdToComponentId.push({noteId, componentId}); } - isNoteContentReloaded(noteId, componentId) { + isNoteContentReloaded(noteId: string, componentId: string) { if (!noteId) { return false; } @@ -107,11 +150,11 @@ export default class LoadResults { return this.contentNoteIdToComponentId.find(l => l.noteId === noteId && l.componentId !== componentId); } - addOption(name) { + addOption(name: string) { this.optionNames.push(name); } - isOptionReloaded(name) { + isOptionReloaded(name: string) { return this.optionNames.includes(name); } @@ -119,7 +162,7 @@ export default class LoadResults { return this.optionNames; } - addAttachmentRow(attachment) { + addAttachmentRow(attachment: AttachmentRow) { this.attachmentRows.push(attachment); } diff --git a/src/public/app/services/note_attribute_cache.js b/src/public/app/services/note_attribute_cache.ts similarity index 85% rename from src/public/app/services/note_attribute_cache.js rename to src/public/app/services/note_attribute_cache.ts index e0696bda1..955744d71 100644 --- a/src/public/app/services/note_attribute_cache.js +++ b/src/public/app/services/note_attribute_cache.ts @@ -1,3 +1,5 @@ +import FAttribute from "../entities/fattribute.js"; + /** * 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. */ class NoteAttributeCache { + attributes: Record; + constructor() { - /** @property {Object.} */ this.attributes = {}; } diff --git a/src/public/app/services/open.js b/src/public/app/services/open.ts similarity index 68% rename from src/public/app/services/open.js rename to src/public/app/services/open.ts index 8a89cc615..da41da3b1 100644 --- a/src/public/app/services/open.js +++ b/src/public/app/services/open.ts @@ -1,25 +1,31 @@ import utils from "./utils.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') { throw new Error(`Unrecognized type '${type}', should be 'notes' or 'attachments'`); } } -function getFileUrl(type, noteId) { +function getFileUrl(type: string, noteId?: string) { checkType(type); return getUrlForDownload(`api/${type}/${noteId}/download`); } -function getOpenFileUrl(type, noteId) { +function getOpenFileUrl(type: string, noteId: string) { checkType(type); return getUrlForDownload(`api/${type}/${noteId}/open`); } -function download(url) { +function download(url: string) { if (utils.isElectron()) { 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 download(url); } -function downloadAttachment(attachmentId) { +function downloadAttachment(attachmentId: string) { const url = `${getFileUrl('attachments', attachmentId)}?${Date.now()}`; // don't use cache download(url); } -async function openCustom(type, entityId, mime) { +async function openCustom(type: string, entityId: string, mime: string) { checkType(type); if (!utils.isElectron() || utils.isMac()) { return; } - const resp = await server.post(`${type}/${entityId}/save-to-tmp-dir`); + const resp = await server.post(`${type}/${entityId}/save-to-tmp-dir`); let filePath = resp.tmpFilePath; - const {exec} = utils.dynamicRequire('child_process'); + const exec = utils.dynamicRequire('child_process').exec as ExecFunction; const platform = process.platform; if (platform === 'linux') { // 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 openFileWithTerminal = (terminal) => { + const openFileWithTerminal = (terminal: string) => { const command = `${terminal} -e 'mimeopen -d "${filePath}"'`; console.log(`Open Note custom: ${command} `); 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]; if (!terminal) { 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; } exec(`which ${terminal}`, (error, stdout, stderr) => { @@ -93,21 +100,27 @@ async function openCustom(type, entityId, mime) { exec(command, (err, stdout, stderr) => { if (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; } }); } else { 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 openAttachmentCustom = async (attachmentId, mime) => await openCustom('attachments', attachmentId, mime); +const openNoteCustom = + 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`); download(url); @@ -116,7 +129,7 @@ function downloadRevision(noteId, revisionId) { /** * @param url - should be without initial slash!!! */ -function getUrlForDownload(url) { +function getUrlForDownload(url: string) { if (utils.isElectron()) { // electron needs absolute URL, so we extract current host, port, protocol return `${getHost()}/${url}`; @@ -127,18 +140,18 @@ function getUrlForDownload(url) { } } -function canOpenInBrowser(mime) { +function canOpenInBrowser(mime: string) { return mime === "application/pdf" || mime.startsWith("image") || mime.startsWith("audio") || mime.startsWith("video"); } -async function openExternally(type, entityId, mime) { +async function openExternally(type: string, entityId: string, mime: string) { checkType(type); if (utils.isElectron()) { - const resp = await server.post(`${type}/${entityId}/save-to-tmp-dir`); + const resp = await server.post(`${type}/${entityId}/save-to-tmp-dir`); const electron = utils.dynamicRequire('electron'); 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 openAttachmentExternally = async (attachmentId, mime) => await openExternally('attachments', attachmentId, mime); +const openNoteExternally = + async (noteId: string, mime: string) => await openExternally('notes', noteId, mime); +const openAttachmentExternally = + async (attachmentId: string, mime: string) => await openExternally('attachments', attachmentId, mime); function getHost() { const url = new URL(window.location.href); return `${url.protocol}//${url.hostname}:${url.port}`; } -async function openDirectory(directory) { +async function openDirectory(directory: string) { try { if (utils.isElectron()) { const electron = utils.dynamicRequire('electron'); @@ -177,7 +192,7 @@ async function openDirectory(directory) { } else { 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) console.error('Error:', err.message); } diff --git a/src/public/app/services/options.js b/src/public/app/services/options.js deleted file mode 100644 index 954832eb6..000000000 --- a/src/public/app/services/options.js +++ /dev/null @@ -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; diff --git a/src/public/app/services/options.ts b/src/public/app/services/options.ts new file mode 100644 index 000000000..4f7e15f79 --- /dev/null +++ b/src/public/app/services/options.ts @@ -0,0 +1,79 @@ + +import server from "./server.js"; + +type OptionValue = string | number; + +class Options { + initializedPromise: Promise; + private arr!: Record; + + constructor() { + this.initializedPromise = server.get>('options').then(data => this.load(data)); + } + + load(arr: Record) { + 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 = {}; + 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; \ No newline at end of file diff --git a/src/public/app/services/promoted_attribute_definition_parser.js b/src/public/app/services/promoted_attribute_definition_parser.ts similarity index 65% rename from src/public/app/services/promoted_attribute_definition_parser.js rename to src/public/app/services/promoted_attribute_definition_parser.ts index 5ab5e94ad..35d0f5ad0 100644 --- a/src/public/app/services/promoted_attribute_definition_parser.js +++ b/src/public/app/services/promoted_attribute_definition_parser.ts @@ -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 defObj = {}; + const defObj: DefinitionObject = {}; for (const token of tokens) { if (token === 'promoted') { defObj.isPromoted = true; } 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)) { - defObj.multiplicity = token; + defObj.multiplicity = token as Multiplicity; } else if (token.startsWith('precision')) { const chunks = token.split('='); diff --git a/src/public/app/services/protected_session_holder.js b/src/public/app/services/protected_session_holder.ts similarity index 88% rename from src/public/app/services/protected_session_holder.js rename to src/public/app/services/protected_session_holder.ts index 8fdae303c..9b0287999 100644 --- a/src/public/app/services/protected_session_holder.js +++ b/src/public/app/services/protected_session_holder.ts @@ -1,3 +1,4 @@ +import FNote from "../entities/fnote.js"; import server from "./server.js"; function enableProtectedSession() { @@ -20,7 +21,7 @@ async function touchProtectedSession() { } } -function touchProtectedSessionIfNecessary(note) { +function touchProtectedSessionIfNecessary(note: FNote) { if (note && note.isProtected && isProtectedSessionAvailable()) { touchProtectedSession(); } diff --git a/src/public/app/services/server.js b/src/public/app/services/server.ts similarity index 69% rename from src/public/app/services/server.js rename to src/public/app/services/server.ts index b715a426c..f1499c660 100644 --- a/src/public/app/services/server.js +++ b/src/public/app/services/server.ts @@ -1,13 +1,39 @@ import utils from './utils.js'; import ValidationError from "./validation_error.js"; -async function getHeaders(headers) { +type Headers = Record; + +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 activeNoteContext = appContext.tabManager ? appContext.tabManager.getActiveContext() : null; // 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 - const allHeaders = { + const allHeaders: Headers = { 'trilium-component-id': glob.componentId, 'trilium-local-now-datetime': utils.localNowDateTime(), 'trilium-hoisted-note-id': activeNoteContext ? activeNoteContext.hoistedNoteId : null, @@ -28,31 +54,31 @@ async function getHeaders(headers) { return allHeaders; } -async function getWithSilentNotFound(url, componentId) { - return await call('GET', url, componentId, { silentNotFound: true }); +async function getWithSilentNotFound(url: string, componentId?: string) { + return await call('GET', url, componentId, { silentNotFound: true }); } -async function get(url, componentId) { - return await call('GET', url, componentId); +async function get(url: string, componentId?: string) { + return await call('GET', url, componentId); } -async function post(url, data, componentId) { - return await call('POST', url, componentId, { data }); +async function post(url: string, data?: unknown, componentId?: string) { + return await call('POST', url, componentId, { data }); } -async function put(url, data, componentId) { - return await call('PUT', url, componentId, { data }); +async function put(url: string, data?: unknown, componentId?: string) { + return await call('PUT', url, componentId, { data }); } -async function patch(url, data, componentId) { - return await call('PATCH', url, componentId, { data }); +async function patch(url: string, data: unknown, componentId?: string) { + return await call('PATCH', url, componentId, { data }); } -async function remove(url, componentId) { - return await call('DELETE', url, componentId); +async function remove(url: string, componentId?: string) { + return await call('DELETE', url, componentId); } -async function upload(url, fileToUpload) { +async function upload(url: string, fileToUpload: File) { const formData = new FormData(); formData.append('upload', fileToUpload); @@ -68,11 +94,17 @@ async function upload(url, fileToUpload) { } let idCounter = 1; -const idToRequestMap = {}; + +const idToRequestMap: Record = {}; let maxKnownEntityChangeId = 0; -async function call(method, url, componentId, options = {}) { +interface CallOptions { + data?: unknown; + silentNotFound?: boolean; +} + +async function call(method: string, url: string, componentId?: string, options: CallOptions = {}) { let resp; const headers = await getHeaders({ @@ -98,7 +130,7 @@ async function call(method, url, componentId, options = {}) { url: `/${window.glob.baseApiUrl}${url}`, data: data }); - }); + }) as any; } else { 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)); } - 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 { return new Promise((res, rej) => { - const options = { + const options: JQueryAjaxSettings = { url: window.glob.baseApiUrl + url, type: method, headers: headers, timeout: 60000, success: (body, textStatus, jqXhr) => { - const respHeaders = {}; + const respHeaders: Headers = {}; jqXhr.getAllResponseHeaders().trim().split(/[\r\n]+/).forEach(line => { const parts = line.split(': '); const header = parts.shift(); - respHeaders[header] = parts.join(': '); + if (header) { + respHeaders[header] = parts.join(': '); + } }); res({ @@ -165,7 +199,7 @@ function ajax(url, method, data, headers, silentNotFound) { if (utils.isElectron()) { 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) { handleSuccessfulResponse(arg); } @@ -182,8 +216,8 @@ if (utils.isElectron()) { delete idToRequestMap[arg.requestId]; }); - function handleSuccessfulResponse(arg) { - if (arg.headers['Content-Type'] === 'application/json') { + function handleSuccessfulResponse(arg: Arg) { + if (arg.headers['Content-Type'] === 'application/json' && typeof arg.body === "string") { 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; if (typeof response === 'string') { try { response = JSON.parse(response); - message = response.message; + message = (response as any).message; } catch (e) {} } 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') { - toastService.showError(message); + toastService.showError(messageStr); throw new ValidationError({ requestUrl: url, method, @@ -222,7 +258,7 @@ async function reportError(method, url, statusCode, response) { }); } else { const title = `${statusCode} ${method} ${url}`; - toastService.showErrorTitleAndMessage(title, message); + toastService.showErrorTitleAndMessage(title, messageStr); toastService.throwError(`${title} - ${message}`); } } diff --git a/src/public/app/services/spaced_update.js b/src/public/app/services/spaced_update.ts similarity index 81% rename from src/public/app/services/spaced_update.js rename to src/public/app/services/spaced_update.ts index bfac5f056..4ac0fdd23 100644 --- a/src/public/app/services/spaced_update.js +++ b/src/public/app/services/spaced_update.ts @@ -1,5 +1,13 @@ +type Callback = () => Promise; + 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.lastUpdated = Date.now(); this.changed = false; @@ -52,7 +60,7 @@ export default class SpacedUpdate { } } - async allowUpdateWithoutChange(callback) { + async allowUpdateWithoutChange(callback: Callback) { this.changeForbidden = true; try { diff --git a/src/public/app/services/toast.js b/src/public/app/services/toast.ts similarity index 79% rename from src/public/app/services/toast.js rename to src/public/app/services/toast.ts index 31991a3c9..ba1cde259 100644 --- a/src/public/app/services/toast.js +++ b/src/public/app/services/toast.ts @@ -1,7 +1,17 @@ import ws from "./ws.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 = $( `