Merge pull request #273 from TriliumNext/feature/client_typescript_port1

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

1
.gitattributes vendored Normal file
View File

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

View File

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

108
package-lock.json generated
View File

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

View File

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

View File

@ -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<HTMLElement>;
}
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<BeforeUploadListener>[];
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<void>} */
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<BeforeUploadListener>(obj));
}
}

View File

@ -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<void> | 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<void>} */
handleEvent(name, data) {
handleEvent(name: string, data: unknown): Promise<unknown> | 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<void>} */
triggerEvent(name, data = {}) {
return this.parent.triggerEvent(name, data);
triggerEvent(name: string, data = {}): Promise<unknown> | undefined | null {
return this.parent?.triggerEvent(name, data);
}
/** @returns {Promise<void>} */
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<unknown> | 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<unknown>, data: unknown) {
if (typeof fun !== 'function') {
return;
}

View File

@ -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;
@ -57,7 +59,7 @@ class ZoomComponent extends Component {
this.setZoomFactorAndSave(1);
}
setZoomFactorAndSaveEvent({zoomFactor}) {
setZoomFactorAndSaveEvent({ zoomFactor }: { zoomFactor: number }) {
this.setZoomFactorAndSave(zoomFactor);
}
}

View File

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

View File

@ -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<FNote>} */
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<FAttribute, "froca"> {
const dto: any = Object.assign({}, this);
delete dto.froca;
return dto;

View File

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

View File

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

View File

@ -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<FBranch, "froca"> {
const pojo = {...this} as any;
delete pojo.froca;
return pojo;
}

View File

@ -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<string>} 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.<string, 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<string, string>;
childToBranch: Record<string, string>;
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.<string, string>} */
this.parentToBranch = {};
/** @type {Object.<string, string>} */
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<string, number> = {};
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<FNote[]>} */
async getChildNotes() {
return await this.froca.getNotes(this.children);
}
/** @returns {Promise<FAttachment[]>} */
async getAttachments() {
if (!this.attachments) {
this.attachments = await this.froca.getAttachmentsForNote(this.noteId);
@ -259,14 +269,12 @@ class FNote {
return this.attachments;
}
/** @returns {Promise<FAttachment[]>} */
async getAttachmentsByRole(role) {
async getAttachmentsByRole(role: string) {
return (await this.getAttachments())
.filter(attachment => attachment.role === role);
}
/** @returns {Promise<FAttachment>} */
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<NotePathRecord>}
*/
getSortedNotePathRecords(hoistedNoteId = 'root') {
const isHoistedRoot = hoistedNoteId === 'root';
@ -477,13 +479,9 @@ class FNote {
}
/**
* @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<FNote>|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<FNote[]>}
* @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<string> | 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<FNote[]>}
*/
async getTargetRelationSourceNotes() {
const targetRelations = this.getTargetRelations();
@ -882,13 +876,11 @@ class FNote {
/**
* @deprecated use getBlob() instead
* @return {Promise<FBlob>}
*/
async getNoteComplement() {
return this.getBlob();
}
/** @return {Promise<FBlob>} */
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<FNote, "froca"> {
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<NoteMetaData>(`notes/${this.noteId}/metadata`);
}
}

View File

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

View File

@ -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<FNoteRow>(`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<FNoteRow>(`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<FNoteRow>(`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<FNoteRow>(`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<FNoteRow>(`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<FNoteRow>('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<FNoteRow>('special-notes/search-note', opts);
await ws.waitForMaxKnownEntityChangeId();

View File

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

View File

@ -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<void>;
notes!: Record<string, FNote>;
branches!: Record<string, FBranch>;
attributes!: Record<string, FAttribute>;
attachments!: Record<string, FAttachment>;
blobPromises!: Record<string, Promise<void | FBlob> | null>;
constructor() {
this.initializedPromise = this.loadInitialTree();
}
async loadInitialTree() {
const resp = await server.get('tree');
const resp = await server.get<SubtreeResponse>('tree');
// clear the cache only directly before adding new content which is important for e.g., switching to protected session
/** @type {Object.<string, FNote>} */
this.notes = {};
/** @type {Object.<string, FBranch>} */
this.branches = {};
/** @type {Object.<string, FAttribute>} */
this.attributes = {};
/** @type {Object.<string, FAttachment>} */
this.attachments = {};
/** @type {Object.<string, Promise<FBlob>>} */
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<SubtreeResponse>(`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<string>();
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<SubtreeResponse>('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<SearchNoteResponse>(`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<FNote[]>} */
async getNotes(noteIds, silentNotFoundError = false) {
async getNotes(noteIds: string[], silentNotFoundError = false): Promise<FNote[]> {
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<boolean>} */
async noteExists(noteId) {
async noteExists(noteId: string): Promise<boolean> {
const notes = await this.getNotes([noteId], true);
return notes.length === 1;
}
/** @returns {Promise<FNote>} */
async getNote(noteId, silentNotFoundError = false) {
async getNote(noteId: string, silentNotFoundError = false): Promise<FNote | null> {
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<FAttachment>} */
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<FAttachmentRow[]>(`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<FAttachment[]>} */
async getAttachmentsForNote(noteId) {
const attachmentRows = await server.get(`notes/${noteId}/attachments`);
async getAttachmentsForNote(noteId: string) {
const attachmentRows = await server.get<FAttachmentRow[]>(`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<FBlob>} */
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<FBlobRow>(`${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;

View File

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

View File

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

View File

@ -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<string, Record<string, unknown>>;
private noteIdToComponentId: Record<string, string[]>;
private componentIdToNoteIds: Record<string, string[]>;
private branchRows: BranchRow[];
private attributeRows: AttributeRow[];
private revisionRows: RevisionRow[];
private noteReorderings: string[];
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
private optionNames: string[];
private attachmentRows: AttachmentRow[];
constructor(entityChanges: EntityChange[]) {
this.entities = {};
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);
}
if (componentId) {
if (!this.noteIdToComponentId[noteId].includes(componentId)) {
this.noteIdToComponentId[noteId].push(componentId);
}
this.componentIdToNoteIds[componentId] = this.componentIdToNoteIds[componentId] || [];
this.componentIdToNoteIds[componentId] = this.componentIdToNoteIds[componentId] || [];
if (!this.componentIdToNoteIds[componentId]) {
this.componentIdToNoteIds[componentId].push(noteId);
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);
}

View File

@ -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<string, FAttribute[]>;
constructor() {
/** @property {Object.<string, BAttribute[]>} */
this.attributes = {};
}

View File

@ -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<TmpResponse>(`${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<TmpResponse>(`${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);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,39 @@
import utils from './utils.js';
import ValidationError from "./validation_error.js";
async function getHeaders(headers) {
type Headers = Record<string, string | null | undefined>;
type Method = string;
interface Response {
headers: Headers;
body: unknown;
}
interface Arg extends Response {
statusCode: number;
method: Method;
url: string;
requestId: string;
}
interface RequestData {
resolve: (value: unknown) => any;
reject: (reason: unknown) => any;
silentNotFound: boolean;
}
export interface StandardResponse {
success: boolean;
}
async function getHeaders(headers?: Headers) {
const appContext = (await import('../components/app_context.js')).default;
const 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<T>(url: string, componentId?: string) {
return await call<T>('GET', url, componentId, { silentNotFound: true });
}
async function get(url, componentId) {
return await call('GET', url, componentId);
async function get<T>(url: string, componentId?: string) {
return await call<T>('GET', url, componentId);
}
async function post(url, data, componentId) {
return await call('POST', url, componentId, { data });
async function post<T>(url: string, data?: unknown, componentId?: string) {
return await call<T>('POST', url, componentId, { data });
}
async function put(url, data, componentId) {
return await call('PUT', url, componentId, { data });
async function put<T>(url: string, data?: unknown, componentId?: string) {
return await call<T>('PUT', url, componentId, { data });
}
async function patch(url, data, componentId) {
return await call('PATCH', url, componentId, { data });
async function patch<T>(url: string, data: unknown, componentId?: string) {
return await call<T>('PATCH', url, componentId, { data });
}
async function remove(url, componentId) {
return await call('DELETE', url, componentId);
async function remove<T>(url: string, componentId?: string) {
return await call<T>('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<string, RequestData> = {};
let maxKnownEntityChangeId = 0;
async function call(method, url, componentId, options = {}) {
interface CallOptions {
data?: unknown;
silentNotFound?: boolean;
}
async function call<T>(method: string, url: string, componentId?: string, options: CallOptions = {}) {
let resp;
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<Response> {
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}`);
}
}

View File

@ -1,5 +1,13 @@
type Callback = () => Promise<void>;
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

55
src/public/app/types.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +1,30 @@
{
"compilerOptions": {
"module": "NodeNext",
"declaration": false,
"sourceMap": true,
"outDir": "./build",
"strict": true,
"noImplicitAny": true,
"resolveJsonModule": true,
"lib": ["ES2022"],
"downlevelIteration": true,
"skipLibCheck": true,
"esModuleInterop": true
"module": "NodeNext",
"declaration": false,
"sourceMap": true,
"outDir": "./build",
"strict": true,
"noImplicitAny": true,
"resolveJsonModule": true,
"lib": [
"ES2022"
],
"downlevelIteration": true,
"skipLibCheck": true,
"esModuleInterop": true,
},
"include": [
"./src/**/*.js",
"./src/**/*.ts",
"./*.ts",
"./spec/**/*.ts",
"./spec-es6/**/*.ts"
"./src/**/*.js",
"./src/**/*.ts",
"./*.ts",
"./spec/**/*.ts",
"./spec-es6/**/*.ts"
],
"exclude": [
"./src/public/**/*",
"./node_modules/**/*"
],
"exclude": ["./node_modules/**/*"],
"files": [
"src/types.d.ts"
]

24
tsconfig.webpack.json Normal file
View File

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

View File

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