From 39b82b4c980a03a7164468853ae500bb8ba93de8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 19:12:42 +0200 Subject: [PATCH 01/66] chore(client/ts): port services/attribute_autocomplete --- .../{attribute_autocomplete.js => attribute_autocomplete.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/public/app/services/{attribute_autocomplete.js => attribute_autocomplete.ts} (91%) diff --git a/src/public/app/services/attribute_autocomplete.js b/src/public/app/services/attribute_autocomplete.ts similarity index 91% rename from src/public/app/services/attribute_autocomplete.js rename to src/public/app/services/attribute_autocomplete.ts index 761d5dbeb..04c601f46 100644 --- a/src/public/app/services/attribute_autocomplete.js +++ b/src/public/app/services/attribute_autocomplete.ts @@ -20,7 +20,7 @@ function initAttributeNameAutocomplete({ $el, attributeType, open }) { source: async (term, cb) => { const type = typeof attributeType === "function" ? attributeType() : attributeType; - const names = await server.get(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`); + const names = await server.get(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`); const result = names.map(name => ({name})); cb(result); @@ -52,7 +52,7 @@ async function initLabelValueAutocomplete({ $el, open, nameCallback }) { return; } - const attributeValues = (await server.get(`attribute-values/${encodeURIComponent(attributeName)}`)) + const attributeValues = (await server.get(`attribute-values/${encodeURIComponent(attributeName)}`)) .map(attribute => ({ value: attribute })); if (attributeValues.length === 0) { From 5d4e7a16fd38d99d15d156a14dfc2774f873fac1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 19:21:02 +0200 Subject: [PATCH 02/66] chore(client/ts): port services/attribute_parser --- ...ttribute_parser.js => attribute_parser.ts} | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) rename src/public/app/services/{attribute_parser.js => attribute_parser.ts} (86%) diff --git a/src/public/app/services/attribute_parser.js b/src/public/app/services/attribute_parser.ts similarity index 86% rename from src/public/app/services/attribute_parser.js rename to src/public/app/services/attribute_parser.ts index fc032a252..07677a1e5 100644 --- a/src/public/app/services/attribute_parser.js +++ b/src/public/app/services/attribute_parser.ts @@ -1,14 +1,30 @@ +import FAttribute, { AttributeType, FAttributeRow } from "../entities/fattribute.js"; import utils from "./utils.js"; -function lex(str) { +interface Token { + text: string; + startIndex: number; + endIndex: number; +} + +interface Attribute { + type: AttributeType; + name: string; + isInheritable: boolean; + value?: string; + startIndex: number; + endIndex: number; +} + +function lex(str: string) { str = str.trim(); - const tokens = []; + const tokens: Token[] = []; - let quotes = false; + let quotes: boolean | string = false; let currentWord = ''; - function isOperatorSymbol(chr) { + function isOperatorSymbol(chr: string) { return ['=', '*', '>', '<', '!'].includes(chr); } @@ -24,7 +40,7 @@ function lex(str) { /** * @param endIndex - index of the last character of the token */ - function finishWord(endIndex) { + function finishWord(endIndex: number) { if (currentWord === '') { return; } @@ -107,7 +123,7 @@ function lex(str) { return tokens; } -function checkAttributeName(attrName) { +function checkAttributeName(attrName: string) { if (attrName.length === 0) { throw new Error("Attribute name is empty, please fill the name."); } @@ -117,10 +133,10 @@ function checkAttributeName(attrName) { } } -function parse(tokens, str, allowEmptyRelations = false) { - const attrs = []; +function parse(tokens: Token[], str: string, allowEmptyRelations = false) { + const attrs: Attribute[] = []; - function context(i) { + function context(i: number) { let { startIndex, endIndex } = tokens[i]; startIndex = Math.max(0, startIndex - 20); endIndex = Math.min(str.length, endIndex + 20); @@ -151,7 +167,7 @@ function parse(tokens, str, allowEmptyRelations = false) { checkAttributeName(labelName); - const attr = { + const attr: Attribute = { type: 'label', name: labelName, isInheritable: isInheritable(), @@ -177,7 +193,7 @@ function parse(tokens, str, allowEmptyRelations = false) { checkAttributeName(relationName); - const attr = { + const attr: Attribute = { type: 'relation', name: relationName, isInheritable: isInheritable(), @@ -216,7 +232,7 @@ function parse(tokens, str, allowEmptyRelations = false) { return attrs; } -function lexAndParse(str, allowEmptyRelations = false) { +function lexAndParse(str: string, allowEmptyRelations = false) { const tokens = lex(str); return parse(tokens, str, allowEmptyRelations); From 5d5a68170aec43b17e2d381635c3989d5853fe09 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 19:23:07 +0200 Subject: [PATCH 03/66] chore(client/ts): port services/attribute_renderer --- .../{attribute_renderer.js => attribute_renderer.ts} | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) rename src/public/app/services/{attribute_renderer.js => attribute_renderer.ts} (95%) diff --git a/src/public/app/services/attribute_renderer.js b/src/public/app/services/attribute_renderer.ts similarity index 95% rename from src/public/app/services/attribute_renderer.js rename to src/public/app/services/attribute_renderer.ts index 8916665a3..da620f365 100644 --- a/src/public/app/services/attribute_renderer.js +++ b/src/public/app/services/attribute_renderer.ts @@ -20,7 +20,11 @@ async function renderAttribute(attribute, renderIsInheritable) { // when the relation has just been created, then it might not have a value if (attribute.value) { $attr.append(document.createTextNode(`~${attribute.name}${isInheritable}=`)); - $attr.append(await createLink(attribute.value)); + + const link = await createLink(attribute.value); + if (link) { + $attr.append(link); + } } } else { ws.logError(`Unknown attr type: ${attribute.type}`); From 47aed18ff44aac7e3625d37f96c1b80d2c913a13 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 19:36:15 +0200 Subject: [PATCH 04/66] chore(client/ts): port services/i18n --- src/public/app/services/{i18n.js => i18n.ts} | 0 src/public/app/types.d.ts | 23 ++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) rename src/public/app/services/{i18n.js => i18n.ts} (100%) diff --git a/src/public/app/services/i18n.js b/src/public/app/services/i18n.ts similarity index 100% rename from src/public/app/services/i18n.js rename to src/public/app/services/i18n.ts diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index f80a3fbec..0d55f41f6 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -1,4 +1,7 @@ -import FNote from "./entities/fnote"; +import type FNote from "./entities/fnote"; +import type { BackendModule, i18n } from "i18next"; +import type { Froca } from "./services/froca-interface"; +import type { HttpBackendOptions } from "i18next-http-backend"; interface ElectronProcess { type: string; @@ -30,6 +33,7 @@ interface CustomGlobals { isMainWindow: boolean; maxEntityChangeIdAtLoad: number; maxEntityChangeSyncIdAtLoad: number; + assetPath: string; } type RequireMethod = (moduleName: string) => any; @@ -44,12 +48,17 @@ declare global { } interface JQuery { - autocomplete: (action: "close") => void; + // 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; + var logError: (message: string) => void; + var logInfo: (message: string) => void; + var glob: CustomGlobals; + var require: RequireMethod; + var __non_webpack_require__: RequireMethod | undefined; + + // Libraries + // Replace once library loader is replaced with webpack. + var i18next: i18n; + var i18nextHttpBackend: BackendModule; } From d9a1bd78b04da3b780b975e682b4bfa5cc08813c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 19:36:24 +0200 Subject: [PATCH 05/66] chore(client/ts): port services/attributes --- src/public/app/services/{attributes.js => attributes.ts} | 0 src/public/app/services/options.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/public/app/services/{attributes.js => attributes.ts} (100%) diff --git a/src/public/app/services/attributes.js b/src/public/app/services/attributes.ts similarity index 100% rename from src/public/app/services/attributes.js rename to src/public/app/services/attributes.ts diff --git a/src/public/app/services/options.ts b/src/public/app/services/options.ts index 4f7e15f79..23cddcfd9 100644 --- a/src/public/app/services/options.ts +++ b/src/public/app/services/options.ts @@ -1,7 +1,7 @@ import server from "./server.js"; -type OptionValue = string | number; +type OptionValue = string; class Options { initializedPromise: Promise; From 9071a97730cf0b25438ec77fe42cc2f39a1edfb9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 20:03:38 +0200 Subject: [PATCH 06/66] chore(client/ts): fix one dependency to server --- src/public/app/server_types.ts | 18 ++++++++++++++++++ src/public/app/services/froca_updater.ts | 2 +- src/public/app/services/load_results.ts | 2 +- src/public/app/services/ws.ts | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 src/public/app/server_types.ts diff --git a/src/public/app/server_types.ts b/src/public/app/server_types.ts new file mode 100644 index 000000000..cdbb1c9ec --- /dev/null +++ b/src/public/app/server_types.ts @@ -0,0 +1,18 @@ +// TODO: Deduplicate with src/services/entity_changes_interface.ts +export interface EntityChange { + id?: number | null; + noteId?: string; + entityName: string; + entityId: string; + entity?: any; + positions?: Record; + hash: string; + utcDateChanged?: string; + utcDateModified?: string; + utcDateCreated?: string; + isSynced: boolean | 1 | 0; + isErased: boolean | 1 | 0; + componentId?: string | null; + changeId?: string | null; + instanceId?: string | null; +} \ No newline at end of file diff --git a/src/public/app/services/froca_updater.ts b/src/public/app/services/froca_updater.ts index 2c850881d..d936a5495 100644 --- a/src/public/app/services/froca_updater.ts +++ b/src/public/app/services/froca_updater.ts @@ -7,7 +7,7 @@ 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"; +import type { EntityChange } from "../server_types.js" async function processEntityChanges(entityChanges: EntityChange[]) { const loadResults = new LoadResults(entityChanges); diff --git a/src/public/app/services/load_results.ts b/src/public/app/services/load_results.ts index 7945a943e..7627fc2c8 100644 --- a/src/public/app/services/load_results.ts +++ b/src/public/app/services/load_results.ts @@ -1,4 +1,4 @@ -import { EntityChange } from "../../../services/entity_changes_interface.js"; +import { EntityChange } from "../server_types.js"; interface BranchRow { branchId: string; diff --git a/src/public/app/services/ws.ts b/src/public/app/services/ws.ts index 86f047dae..061673470 100644 --- a/src/public/app/services/ws.ts +++ b/src/public/app/services/ws.ts @@ -4,8 +4,8 @@ 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'; +import { EntityChange } from '../server_types.js'; type MessageHandler = (message: any) => void; const messageHandlers: MessageHandler[] = []; From 8ec0efe5b3f0282412649800754def01c9cc152f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 20:14:48 +0200 Subject: [PATCH 07/66] chore(client/ts): fix another dependency to server --- tsconfig.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index cd4ea40e2..8c9b11075 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,12 +18,12 @@ "./src/**/*.js", "./src/**/*.ts", "./*.ts", - "./spec/**/*.ts", - "./spec-es6/**/*.ts" + "./spec/**/*.ts" ], "exclude": [ "./src/public/**/*", - "./node_modules/**/*" + "./node_modules/**/*", + "./spec-es6/**/*.ts" ], "files": [ "src/types.d.ts" From c8866d2669c47718e70b1a5cd822802699f46718 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 20:27:27 +0200 Subject: [PATCH 08/66] chore(client/ts): port services/syntax_highlight --- src/public/app/services/syntax_highlight.ts | 94 +++++++++++++++++++++ src/public/app/types.d.ts | 8 +- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/public/app/services/syntax_highlight.ts diff --git a/src/public/app/services/syntax_highlight.ts b/src/public/app/services/syntax_highlight.ts new file mode 100644 index 000000000..c9f9449eb --- /dev/null +++ b/src/public/app/services/syntax_highlight.ts @@ -0,0 +1,94 @@ +import library_loader from "./library_loader.js"; +import mime_types from "./mime_types.js"; +import options from "./options.js"; + +export function getStylesheetUrl(theme) { + if (!theme) { + return null; + } + + const defaultPrefix = "default:"; + if (theme.startsWith(defaultPrefix)) { + return `${window.glob.assetPath}/node_modules/@highlightjs/cdn-assets/styles/${theme.substr(defaultPrefix.length)}.min.css`; + } + + return null; +} + +/** + * Identifies all the code blocks (as `pre code`) under the specified hierarchy and uses the highlight.js library to obtain the highlighted text which is then applied on to the code blocks. + * + * @param $container the container under which to look for code blocks and to apply syntax highlighting to them. + */ +export async function applySyntaxHighlight($container) { + if (!isSyntaxHighlightEnabled()) { + return; + } + + const codeBlocks = $container.find("pre code"); + for (const codeBlock of codeBlocks) { + const normalizedMimeType = extractLanguageFromClassList(codeBlock); + if (!normalizedMimeType) { + continue; + } + + applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType); + } +} + +/** + * Applies syntax highlight to the given code block (assumed to be
), using highlight.js.
+ * 
+ * @param {*} $codeBlock 
+ * @param {*} normalizedMimeType 
+ */
+export async function applySingleBlockSyntaxHighlight($codeBlock, normalizedMimeType) {
+    $codeBlock.parent().toggleClass("hljs");
+    const text = $codeBlock.text();
+
+    if (!window.hljs) {
+        await library_loader.requireLibrary(library_loader.HIGHLIGHT_JS);
+    }
+
+    let highlightedText = null;
+    if (normalizedMimeType === mime_types.MIME_TYPE_AUTO) {
+        highlightedText = hljs.highlightAuto(text);
+    } else if (normalizedMimeType) {
+        const language = mime_types.getHighlightJsNameForMime(normalizedMimeType);
+        if (language) {
+            highlightedText = hljs.highlight(text, { language });
+        } else {
+            console.warn(`Unknown mime type: ${normalizedMimeType}.`);
+        }
+    }
+    
+    if (highlightedText) {            
+        $codeBlock.html(highlightedText.value);
+    }
+}
+
+/**
+ * Indicates whether syntax highlighting should be enabled for code blocks, by querying the value of the `codeblockTheme` option.
+ * @returns whether syntax highlighting should be enabled for code blocks.
+ */
+export function isSyntaxHighlightEnabled() {
+    const theme = options.get("codeBlockTheme");
+    return theme && theme !== "none";
+}
+
+/**
+ * Given a HTML element, tries to extract the `language-` class name out of it.
+ * 
+ * @param {string} el the HTML element from which to extract the language tag.
+ * @returns the normalized MIME type (e.g. `text-css` instead of `language-text-css`).
+ */
+function extractLanguageFromClassList(el) {
+    const prefix = "language-";
+    for (const className of el.classList) {
+        if (className.startsWith(prefix)) {
+            return className.substring(prefix.length);
+        }
+    }
+
+    return null;
+}
\ No newline at end of file
diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts
index 0d55f41f6..705a16b3a 100644
--- a/src/public/app/types.d.ts
+++ b/src/public/app/types.d.ts
@@ -58,7 +58,13 @@ declare global {
     var __non_webpack_require__: RequireMethod | undefined;
 
     // Libraries
-    // Replace once library loader is replaced with webpack.
+    // TODO: Replace once library loader is replaced with webpack.
     var i18next: i18n;
     var i18nextHttpBackend: BackendModule;
+    var hljs: {
+        highlightAuto(text: string);
+        highlight(text: string, {
+            language: string
+        });
+    };
 }

From e4053de7350241cdd6073bf9fb41d267c40d9403 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:44:07 +0200
Subject: [PATCH 09/66] chore(client/ts): enable server config to compile
 client as well

---
 tsconfig.json | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/tsconfig.json b/tsconfig.json
index 8c9b11075..d1c25dd30 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,6 +7,7 @@
 		"strict": true,
 		"noImplicitAny": true,
 		"resolveJsonModule": true,
+		"allowJs": true,
 		"lib": [
 			"ES2022"
 		],
@@ -21,11 +22,11 @@
 		"./spec/**/*.ts"
 	],
 	"exclude": [
-		"./src/public/**/*",
 		"./node_modules/**/*",
 		"./spec-es6/**/*.ts"
 	],
 	"files": [
-		"src/types.d.ts"
+		"src/types.d.ts",
+		"src/public/app/types.d.ts"
 	]
 }
\ No newline at end of file

From ffd609e0c5c1be909f588cd611d11a96138966f4 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:44:21 +0200
Subject: [PATCH 10/66] chore(client/ts): fix errors related to autocomplete

---
 .../app/services/attribute_autocomplete.ts     | 12 ++++++++++--
 src/public/app/services/options.ts             |  2 +-
 src/public/app/types.d.ts                      | 18 +++++++++++++++++-
 3 files changed, 28 insertions(+), 4 deletions(-)

diff --git a/src/public/app/services/attribute_autocomplete.ts b/src/public/app/services/attribute_autocomplete.ts
index 04c601f46..041129974 100644
--- a/src/public/app/services/attribute_autocomplete.ts
+++ b/src/public/app/services/attribute_autocomplete.ts
@@ -1,11 +1,19 @@
+import { AttributeType } from "../entities/fattribute.js";
 import server from "./server.js";
 
+interface InitOptions {
+    $el: JQuery;
+    attributeType: AttributeType | (() => AttributeType);
+    open: boolean;
+    nameCallback: () => string;
+}
+
 /**
  * @param $el - element on which to init autocomplete
  * @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes
  * @param open - should the autocomplete be opened after init?
  */
-function initAttributeNameAutocomplete({ $el, attributeType, open }) {
+function initAttributeNameAutocomplete({ $el, attributeType, open }: InitOptions) {
     if (!$el.hasClass("aa-input")) {
         $el.autocomplete({
             appendTo: document.querySelector('body'),
@@ -39,7 +47,7 @@ function initAttributeNameAutocomplete({ $el, attributeType, open }) {
     }
 }
 
-async function initLabelValueAutocomplete({ $el, open, nameCallback }) {
+async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) {
     if ($el.hasClass("aa-input")) {
         // we reinit every time because autocomplete seems to have a bug where it retains state from last
         // open even though the value was reset
diff --git a/src/public/app/services/options.ts b/src/public/app/services/options.ts
index 23cddcfd9..138c0402a 100644
--- a/src/public/app/services/options.ts
+++ b/src/public/app/services/options.ts
@@ -1,7 +1,7 @@
 
 import server from "./server.js";
 
-type OptionValue = string;
+type OptionValue = number | string;
 
 class Options {
     initializedPromise: Promise;
diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts
index 705a16b3a..4fb103b79 100644
--- a/src/public/app/types.d.ts
+++ b/src/public/app/types.d.ts
@@ -47,8 +47,24 @@ declare global {
         glob?: CustomGlobals;
     }
 
+    interface AutoCompleteConfig {
+        appendTo?: HTMLElement | null;
+        hint?: boolean;
+        openOnFocus?: boolean;
+        minLength?: number;
+        tabAutocomplete?: boolean
+    }
+
+    type AutoCompleteCallback = (values: AutoCompleteCallbackArgs[]) => void;
+
+    interface AutoCompleteArg {
+        displayKey: "name" | "value";
+        cache: boolean;
+        source: (term: string, cb: AutoCompleteCallback) => void
+    };
+    
     interface JQuery {
-        // autocomplete: (action: "close") => void;
+        autocomplete: (action: "close" | "open" | "destroy" | AutoCompleteConfig, args?: AutoCompleteArg[]) => void;
     }
 
     var logError: (message: string) => void;

From 9c90ffde9dfd5eada1c58b2ae038079f4633de05 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:47:02 +0200
Subject: [PATCH 11/66] chore(client/ts): fix errors in attribute_renderer

---
 src/public/app/services/attribute_renderer.ts | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/src/public/app/services/attribute_renderer.ts b/src/public/app/services/attribute_renderer.ts
index da620f365..b5f8477ec 100644
--- a/src/public/app/services/attribute_renderer.ts
+++ b/src/public/app/services/attribute_renderer.ts
@@ -1,7 +1,9 @@
 import ws from "./ws.js";
 import froca from "./froca.js";
+import FAttribute from "../entities/fattribute.js";
+import FNote from "../entities/fnote.js";
 
-async function renderAttribute(attribute, renderIsInheritable) {
+async function renderAttribute(attribute: FAttribute, renderIsInheritable: boolean) {
     const isInheritable = renderIsInheritable && attribute.isInheritable ? `(inheritable)` : '';
     const $attr = $("");
 
@@ -33,7 +35,7 @@ async function renderAttribute(attribute, renderIsInheritable) {
     return $attr;
 }
 
-function formatValue(val) {
+function formatValue(val: string) {
     if (/^[\p{L}\p{N}\-_,.]+$/u.test(val)) {
         return val;
     }
@@ -51,7 +53,7 @@ function formatValue(val) {
     }
 }
 
-async function createLink(noteId) {
+async function createLink(noteId: string) {
     const note = await froca.getNote(noteId);
 
     if (!note) {
@@ -65,7 +67,7 @@ async function createLink(noteId) {
         .text(note.title);
 }
 
-async function renderAttributes(attributes, renderIsInheritable) {
+async function renderAttributes(attributes: FAttribute[], renderIsInheritable: boolean) {
     const $container = $('');
 
     for (let i = 0; i < attributes.length; i++) {
@@ -93,7 +95,7 @@ const HIDDEN_ATTRIBUTES = [
     'viewType'
 ];
 
-async function renderNormalAttributes(note) {
+async function renderNormalAttributes(note: FNote) {
     const promotedDefinitionAttributes = note.getPromotedDefinitionAttributes();
     let attrs = note.getAttributes();
 

From 8726cc62f3395c4b312519b01281760fbc83eb1d Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:47:55 +0200
Subject: [PATCH 12/66] chore(client/ts): fix errors in syntax_highlight

---
 src/public/app/services/syntax_highlight.ts | 13 +++++--------
 1 file changed, 5 insertions(+), 8 deletions(-)

diff --git a/src/public/app/services/syntax_highlight.ts b/src/public/app/services/syntax_highlight.ts
index c9f9449eb..68ca07ced 100644
--- a/src/public/app/services/syntax_highlight.ts
+++ b/src/public/app/services/syntax_highlight.ts
@@ -2,7 +2,7 @@ import library_loader from "./library_loader.js";
 import mime_types from "./mime_types.js";
 import options from "./options.js";
 
-export function getStylesheetUrl(theme) {
+export function getStylesheetUrl(theme: string) {
     if (!theme) {
         return null;
     }
@@ -20,7 +20,7 @@ export function getStylesheetUrl(theme) {
  * 
  * @param $container the container under which to look for code blocks and to apply syntax highlighting to them.
  */
-export async function applySyntaxHighlight($container) {
+export async function applySyntaxHighlight($container: JQuery) {
     if (!isSyntaxHighlightEnabled()) {
         return;
     }    
@@ -38,11 +38,8 @@ export async function applySyntaxHighlight($container) {
 
 /**
  * Applies syntax highlight to the given code block (assumed to be 
), using highlight.js.
- * 
- * @param {*} $codeBlock 
- * @param {*} normalizedMimeType 
  */
-export async function applySingleBlockSyntaxHighlight($codeBlock, normalizedMimeType) {
+export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery, normalizedMimeType: string) {
     $codeBlock.parent().toggleClass("hljs");
     const text = $codeBlock.text();
 
@@ -79,10 +76,10 @@ export function isSyntaxHighlightEnabled() {
 /**
  * Given a HTML element, tries to extract the `language-` class name out of it.
  * 
- * @param {string} el the HTML element from which to extract the language tag.
+ * @param el the HTML element from which to extract the language tag.
  * @returns the normalized MIME type (e.g. `text-css` instead of `language-text-css`).
  */
-function extractLanguageFromClassList(el) {
+function extractLanguageFromClassList(el: HTMLElement) {
     const prefix = "language-";
     for (const className of el.classList) {
         if (className.startsWith(prefix)) {

From 924453cb6f80621b97ec1a06db13b7980f30f1b7 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:51:47 +0200
Subject: [PATCH 13/66] chore(client/ts): fix errors in attributes

---
 src/public/app/services/attributes.ts | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/src/public/app/services/attributes.ts b/src/public/app/services/attributes.ts
index 7267d9bb8..e8dbcb89e 100644
--- a/src/public/app/services/attributes.ts
+++ b/src/public/app/services/attributes.ts
@@ -1,7 +1,8 @@
 import server from './server.js';
 import froca from './froca.js';
+import FNote from '../entities/fnote.js';
 
-async function addLabel(noteId, name, value = "") {
+async function addLabel(noteId: string, name: string, value: string = "") {
     await server.put(`notes/${noteId}/attribute`, {
         type: 'label',
         name: name,
@@ -9,7 +10,7 @@ async function addLabel(noteId, name, value = "") {
     });
 }
 
-async function setLabel(noteId, name, value = "") {
+async function setLabel(noteId: string, name: string, value: string = "") {
     await server.put(`notes/${noteId}/set-attribute`, {
         type: 'label',
         name: name,
@@ -17,7 +18,7 @@ async function setLabel(noteId, name, value = "") {
     });
 }
 
-async function removeAttributeById(noteId, attributeId) {
+async function removeAttributeById(noteId: string, attributeId: string) {
     await server.remove(`notes/${noteId}/attributes/${attributeId}`);
 }
 
@@ -28,7 +29,7 @@ async function removeAttributeById(noteId, attributeId) {
  *         2. attribute is owned by the template of the note
  *         3. attribute is owned by some note's ancestor and is inheritable
  */
-function isAffecting(attrRow, affectedNote) {
+function isAffecting(this: { isInheritable: boolean }, attrRow: { noteId: string }, affectedNote: FNote) {
     if (!affectedNote || !attrRow) {
         return false;
     }
@@ -48,6 +49,7 @@ function isAffecting(attrRow, affectedNote) {
         }
     }
 
+    // TODO: This doesn't seem right.
     if (this.isInheritable) {
         for (const owningNote of owningNotes) {
             if (owningNote.hasAncestor(attrNote.noteId, true)) {

From 1548b2e3e4e2da9acd8e61d3eb66957f860e7b98 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:52:43 +0200
Subject: [PATCH 14/66] chore(client/ts): fix errors in i18n

---
 src/public/app/services/i18n.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/public/app/services/i18n.ts b/src/public/app/services/i18n.ts
index 7e9be3a09..3d82c0e1d 100644
--- a/src/public/app/services/i18n.ts
+++ b/src/public/app/services/i18n.ts
@@ -4,7 +4,7 @@ import options from "./options.js";
 await library_loader.requireLibrary(library_loader.I18NEXT);
 
 export async function initLocale() {
-    const locale = options.get("locale") || "en";
+    const locale = (options.get("locale") as string) || "en";
 
     await i18next
         .use(i18nextHttpBackend)

From 8454be0a6a3f5b431114d92d3448625470d63632 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:56:18 +0200
Subject: [PATCH 15/66] chore(client/ts): display only js files in progress
 checker

---
 _check_ts_progress.sh | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/_check_ts_progress.sh b/_check_ts_progress.sh
index 424760125..7332a6054 100755
--- a/_check_ts_progress.sh
+++ b/_check_ts_progress.sh
@@ -10,4 +10,4 @@ echo By file
 cloc HEAD \
     --git --md \
     --include-lang=javascript,typescript \
-    --by-file
\ No newline at end of file
+    --by-file | grep \.js\|
\ No newline at end of file

From 36cb07b2f90f405979b0c0b93011cffc010e0dee Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:57:37 +0200
Subject: [PATCH 16/66] chore(client/ts): port services/search

---
 src/public/app/services/{search.js => search.ts} | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)
 rename src/public/app/services/{search.js => search.ts} (54%)

diff --git a/src/public/app/services/search.js b/src/public/app/services/search.ts
similarity index 54%
rename from src/public/app/services/search.js
rename to src/public/app/services/search.ts
index 9a6cd61fc..f3ca50d75 100644
--- a/src/public/app/services/search.js
+++ b/src/public/app/services/search.ts
@@ -1,11 +1,11 @@
 import server from "./server.js";
 import froca from "./froca.js";
 
-async function searchForNoteIds(searchString) {
-    return await server.get(`search/${encodeURIComponent(searchString)}`);
+async function searchForNoteIds(searchString: string) {
+    return await server.get(`search/${encodeURIComponent(searchString)}`);
 }
 
-async function searchForNotes(searchString) {
+async function searchForNotes(searchString: string) {
     const noteIds = await searchForNoteIds(searchString);
 
     return await froca.getNotes(noteIds);

From 52d7e613ec02171c89962297386c85a59679e910 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:57:54 +0200
Subject: [PATCH 17/66] chore(client/ts): remove ported file

---
 src/public/app/services/syntax_highlight.js | 94 ---------------------
 1 file changed, 94 deletions(-)
 delete mode 100644 src/public/app/services/syntax_highlight.js

diff --git a/src/public/app/services/syntax_highlight.js b/src/public/app/services/syntax_highlight.js
deleted file mode 100644
index c9f9449eb..000000000
--- a/src/public/app/services/syntax_highlight.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import library_loader from "./library_loader.js";
-import mime_types from "./mime_types.js";
-import options from "./options.js";
-
-export function getStylesheetUrl(theme) {
-    if (!theme) {
-        return null;
-    }
-
-    const defaultPrefix = "default:";
-    if (theme.startsWith(defaultPrefix)) {        
-        return `${window.glob.assetPath}/node_modules/@highlightjs/cdn-assets/styles/${theme.substr(defaultPrefix.length)}.min.css`;
-    }
-
-    return null;
-}
-
-/**
- * Identifies all the code blocks (as `pre code`) under the specified hierarchy and uses the highlight.js library to obtain the highlighted text which is then applied on to the code blocks.
- * 
- * @param $container the container under which to look for code blocks and to apply syntax highlighting to them.
- */
-export async function applySyntaxHighlight($container) {
-    if (!isSyntaxHighlightEnabled()) {
-        return;
-    }    
-
-    const codeBlocks = $container.find("pre code");
-    for (const codeBlock of codeBlocks) {
-        const normalizedMimeType = extractLanguageFromClassList(codeBlock);
-        if (!normalizedMimeType) {
-            continue;
-        }
-        
-        applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType);
-    }
-}
-
-/**
- * Applies syntax highlight to the given code block (assumed to be 
), using highlight.js.
- * 
- * @param {*} $codeBlock 
- * @param {*} normalizedMimeType 
- */
-export async function applySingleBlockSyntaxHighlight($codeBlock, normalizedMimeType) {
-    $codeBlock.parent().toggleClass("hljs");
-    const text = $codeBlock.text();
-
-    if (!window.hljs) {
-        await library_loader.requireLibrary(library_loader.HIGHLIGHT_JS);
-    }
-
-    let highlightedText = null;
-    if (normalizedMimeType === mime_types.MIME_TYPE_AUTO) {
-        highlightedText = hljs.highlightAuto(text);
-    } else if (normalizedMimeType) {
-        const language = mime_types.getHighlightJsNameForMime(normalizedMimeType);
-        if (language) {
-            highlightedText = hljs.highlight(text, { language });
-        } else {
-            console.warn(`Unknown mime type: ${normalizedMimeType}.`);
-        }
-    }
-    
-    if (highlightedText) {            
-        $codeBlock.html(highlightedText.value);
-    }
-}
-
-/**
- * Indicates whether syntax highlighting should be enabled for code blocks, by querying the value of the `codeblockTheme` option.
- * @returns whether syntax highlighting should be enabled for code blocks.
- */
-export function isSyntaxHighlightEnabled() {
-    const theme = options.get("codeBlockTheme");
-    return theme && theme !== "none";
-}
-
-/**
- * Given a HTML element, tries to extract the `language-` class name out of it.
- * 
- * @param {string} el the HTML element from which to extract the language tag.
- * @returns the normalized MIME type (e.g. `text-css` instead of `language-text-css`).
- */
-function extractLanguageFromClassList(el) {
-    const prefix = "language-";
-    for (const className of el.classList) {
-        if (className.startsWith(prefix)) {
-            return className.substring(prefix.length);
-        }
-    }
-
-    return null;
-}
\ No newline at end of file

From e8d1fe4e842419c4a43694b1cf1fe1b385293912 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 20:58:50 +0200
Subject: [PATCH 18/66] chore(client/ts): port services/sync

---
 src/public/app/services/{sync.js => sync.ts} | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)
 rename src/public/app/services/{sync.js => sync.ts} (73%)

diff --git a/src/public/app/services/sync.js b/src/public/app/services/sync.ts
similarity index 73%
rename from src/public/app/services/sync.js
rename to src/public/app/services/sync.ts
index 181f04933..7c953fb84 100644
--- a/src/public/app/services/sync.js
+++ b/src/public/app/services/sync.ts
@@ -2,8 +2,15 @@ import { t } from './i18n.js';
 import server from './server.js';
 import toastService from "./toast.js";
 
+// TODO: De-duplicate with server once we have a commons.
+interface SyncResult {
+    success: boolean;
+    message: string;
+    errorCode?: string;
+}
+
 async function syncNow(ignoreNotConfigured = false) {
-    const result = await server.post('sync/now');
+    const result = await server.post('sync/now');
 
     if (result.success) {
         toastService.showMessage(t("sync.finished-successfully"));

From 7b7980cefb735e3fc1d0bad3f4293c0c63ca391a Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 21:03:38 +0200
Subject: [PATCH 19/66] chore(client/ts): port services/shortcuts

---
 .../app/services/{shortcuts.js => shortcuts.ts}   | 15 ++++++++++-----
 1 file changed, 10 insertions(+), 5 deletions(-)
 rename src/public/app/services/{shortcuts.js => shortcuts.ts} (68%)

diff --git a/src/public/app/services/shortcuts.js b/src/public/app/services/shortcuts.ts
similarity index 68%
rename from src/public/app/services/shortcuts.js
rename to src/public/app/services/shortcuts.ts
index 9c99ef0d1..05886bac2 100644
--- a/src/public/app/services/shortcuts.js
+++ b/src/public/app/services/shortcuts.ts
@@ -1,14 +1,17 @@
 import utils from "./utils.js";
 
-function removeGlobalShortcut(namespace) {
+type ElementType = HTMLElement | Document;
+type Handler = (e: JQuery.TriggeredEvent) => void;
+
+function removeGlobalShortcut(namespace: string) {
     bindGlobalShortcut('', null, namespace);
 }
 
-function bindGlobalShortcut(keyboardShortcut, handler, namespace = null) {
+function bindGlobalShortcut(keyboardShortcut: string, handler: Handler | null, namespace: string | null = null) {
     bindElShortcut($(document), keyboardShortcut, handler, namespace);
 }
 
-function bindElShortcut($el, keyboardShortcut, handler, namespace = null) {
+function bindElShortcut($el: JQuery, keyboardShortcut: string, handler: Handler | null, namespace: string | null = null) {
     if (utils.isDesktop()) {
         keyboardShortcut = normalizeShortcut(keyboardShortcut);
 
@@ -24,7 +27,9 @@ function bindElShortcut($el, keyboardShortcut, handler, namespace = null) {
         // method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
         if (keyboardShortcut) {
             $el.bind(eventName, keyboardShortcut, e => {
-                handler(e);
+                if (handler) {
+                    handler(e);
+                }
 
                 e.preventDefault();
                 e.stopPropagation();
@@ -36,7 +41,7 @@ function bindElShortcut($el, keyboardShortcut, handler, namespace = null) {
 /**
  * Normalize to the form expected by the jquery.hotkeys.js
  */
-function normalizeShortcut(shortcut) {
+function normalizeShortcut(shortcut: string): string {
     if (!shortcut) {
         return shortcut;
     }

From 650a116193aec5aba670f204d04b70c027b000d1 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Thu, 19 Dec 2024 22:06:42 +0200
Subject: [PATCH 20/66] chore(client/ts): port services/frontend_script_api

---
 src/public/app/components/app_context.ts      |  12 +-
 ...d_script_api.js => frontend_script_api.ts} | 906 +++++++++---------
 src/public/app/services/spaced_update.ts      |   2 +-
 src/public/app/types.d.ts                     |   4 +-
 4 files changed, 476 insertions(+), 448 deletions(-)
 rename src/public/app/services/{frontend_script_api.js => frontend_script_api.ts} (65%)

diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts
index 6372d43ce..783373023 100644
--- a/src/public/app/components/app_context.ts
+++ b/src/public/app/components/app_context.ts
@@ -14,6 +14,7 @@ import MainTreeExecutors from "./main_tree_executors.js";
 import toast from "../services/toast.js";
 import ShortcutComponent from "./shortcut_component.js";
 import { t, initLocale } from "../services/i18n.js";
+import NoteDetailWidget from "../widgets/note_detail.js";
 
 interface Layout {
     getRootWidget: (appContext: AppContext) => RootWidget;
@@ -27,11 +28,18 @@ interface BeforeUploadListener extends Component {
     beforeUnloadEvent(): boolean;
 }
 
-interface TriggerData {
+export type TriggerData = {
     noteId?: string;
     noteIds?: string[];
-    messages?: unknown[];
+    messages?: unknown[];    
     callback?: () => void;
+} | {
+    ntxId: string;
+    notePath: string;
+} | {
+    text: string;
+} | {
+    callback: (value: NoteDetailWidget | PromiseLike) => void
 }
 
 class AppContext extends Component {
diff --git a/src/public/app/services/frontend_script_api.js b/src/public/app/services/frontend_script_api.ts
similarity index 65%
rename from src/public/app/services/frontend_script_api.js
rename to src/public/app/services/frontend_script_api.ts
index 5af37876a..b320518fe 100644
--- a/src/public/app/services/frontend_script_api.js
+++ b/src/public/app/services/frontend_script_api.ts
@@ -9,12 +9,17 @@ import dateNotesService from './date_notes.js';
 import searchService from './search.js';
 import RightPanelWidget from '../widgets/right_panel_widget.js';
 import ws from "./ws.js";
-import appContext from "../components/app_context.js";
+import appContext, { TriggerData } from "../components/app_context.js";
 import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
 import BasicWidget from "../widgets/basic_widget.js";
 import SpacedUpdate from "./spaced_update.js";
 import shortcutService from "./shortcuts.js";
 import dialogService from "./dialog.js";
+import FNote from '../entities/fnote.js';
+import { t } from './i18n.js';
+import NoteContext from '../components/note_context.js';
+import NoteDetailWidget from '../widgets/note_detail.js';
+import Component from '../components/component.js';
 
 
 /**
@@ -28,71 +33,436 @@ import dialogService from "./dialog.js";
  * @var {FrontendScriptApi} api
  */
 
+interface AddToToolbarOpts {
+    title: string;
+    /** callback handling the click on the button */
+    action: () => void;
+    /** id of the button, used to identify the old instances of this button to be replaced
+     * ID is optional because of BC, but not specifying it is deprecated. ID can be alphanumeric only. */
+    id: string;    
+    /** name of the boxicon to be used (e.g. "time" for "bx-time" icon) */
+    icon: string;
+    /** keyboard shortcut for the button, e.g. "alt+t" */
+    shortcut: string;
+}
+
+// TODO: Deduplicate me with the server.
+interface ExecResult {
+    success: boolean;
+    executionResult: unknown;
+    error?: string;
+}
+
+interface Entity {
+    noteId: string;
+}
+
+type Func = ((...args: unknown[]) => unknown) | string;
+
+interface Api {
+    /**
+     * Container of all the rendered script content
+     * */
+    $container: JQuery | null;
+
+    /**
+     * Note where the script started executing, i.e., the (event) entrypoint of the current script execution.
+     */
+    startNote: FNote;
+
+    /**
+     * Note where the script is currently executing, i.e. the note where the currently executing source code is written.
+     */
+    currentNote: FNote;
+
+    /**
+     * Entity whose event triggered this execution.
+     */
+    originEntity: unknown | null;
+
+    /**
+     * day.js library for date manipulation.
+     * See {@link https://day.js.org} for documentation
+     * @see https://day.js.org
+     */
+    dayjs: typeof window.dayjs;
+
+    RightPanelWidget: typeof RightPanelWidget;
+    NoteContextAwareWidget: typeof NoteContextAwareWidget;
+    BasicWidget: typeof BasicWidget;
+
+    /**
+     * Activates note in the tree and in the note detail.
+     *
+     * @param notePath (or noteId)
+     */
+    activateNote(notePath: string): Promise;
+
+    /**
+     * Activates newly created note. Compared to this.activateNote() also makes sure that frontend has been fully synced.
+     *
+     * @param notePath (or noteId)
+     */
+    activateNewNote(notePath: string): Promise;
+
+     /**
+     * Open a note in a new tab.
+     *
+     * @method
+     * @param notePath (or noteId)
+     * @param activate - set to true to activate the new tab, false to stay on the current tab
+     */
+    openTabWithNote(notePath: string, activate: boolean): Promise;
+
+    /**
+     * Open a note in a new split.
+     *
+     * @param notePath (or noteId)
+     * @param activate - set to true to activate the new split, false to stay on the current split
+     */
+    openSplitWithNote(notePath: string, activate: boolean): Promise;
+
+    /**
+     * Adds a new launcher to the launchbar. If the launcher (id) already exists, it will be updated.
+     *
+     * @method
+     * @deprecated you can now create/modify launchers in the top-left Menu -> Configure Launchbar
+     *             for special needs there's also backend API's createOrUpdateLauncher()
+     */
+    addButtonToToolbar(opts: AddToToolbarOpts): void;
+
+     /**
+     * @private
+     */
+    __runOnBackendInner(func: unknown, params: unknown[], transactional: boolean): unknown;
+
+    /**
+     * Executes given anonymous function on the backend.
+     * Internally this serializes the anonymous function into string and sends it to backend via AJAX.
+     * Please make sure that the supplied function is synchronous. Only sync functions will work correctly
+     * with transaction management. If you really know what you're doing, you can call api.runAsyncOnBackendWithManualTransactionHandling()
+     *
+     * @method
+     * @param func - (synchronous) function to be executed on the backend
+     * @param params - list of parameters to the anonymous function to be sent to backend
+     * @returns return value of the executed function on the backend
+     */
+    runOnBackend(func: Func, params: unknown[]): unknown;
+
+    /**
+     * Executes given anonymous function on the backend.
+     * Internally this serializes the anonymous function into string and sends it to backend via AJAX.
+     * This function is meant for advanced needs where an async function is necessary.
+     * In this case, the automatic request-scoped transaction management is not applied,
+     * and you need to manually define transaction via api.transactional().
+     *
+     * If you have a synchronous function, please use api.runOnBackend().
+     *
+     * @method
+     * @param func - (synchronous) function to be executed on the backend
+     * @param params - list of parameters to the anonymous function to be sent to backend
+     * @returns return value of the executed function on the backend
+     */
+    runAsyncOnBackendWithManualTransactionHandling(func: Func, params: unknown[]): unknown;
+
+    /**
+     * This is a powerful search method - you can search by attributes and their values, e.g.:
+     * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://triliumnext.github.io/Docs/Wiki/search.html
+     */
+    searchForNotes(searchString: string): Promise;
+
+    /**
+     * This is a powerful search method - you can search by attributes and their values, e.g.:
+     * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://triliumnext.github.io/Docs/Wiki/search.html
+     */
+    searchForNote(searchString: string): Promise;
+
+    /**
+     * Returns note by given noteId. If note is missing from the cache, it's loaded.
+     */
+    getNote(noteId: string): Promise;
+
+    /**
+     * Returns list of notes. If note is missing from the cache, it's loaded.
+     *
+     * This is often used to bulk-fill the cache with notes which would have to be picked one by one
+     * otherwise (by e.g. createLink())
+     *
+     * @param [silentNotFoundError] - don't report error if the note is not found
+     */
+    getNotes(noteIds: string[], silentNotFoundError: boolean): Promise;
+
+    /**
+     * Update frontend tree (note) cache from the backend.
+     */
+    reloadNotes(noteIds: string[]): Promise;
+
+    /**
+     * Instance name identifies particular Trilium instance. It can be useful for scripts
+     * if some action needs to happen on only one specific instance.
+     */
+    getInstanceName(): string;
+
+    /**
+     * @returns date in YYYY-MM-DD format
+     */
+    formatDateISO: typeof utils.formatDateISO;
+
+    parseDate: typeof utils.parseDate;
+
+    /**
+     * Show an info toast message to the user.
+     */
+    showMessage: typeof toastService.showMessage;
+
+    /**
+     * Show an error toast message to the user.
+     */
+    showError: typeof toastService.showError;
+
+    /**
+     * Show an info dialog to the user.
+     */
+    showInfoDialog: typeof dialogService.info;
+
+    /**
+     * Show confirm dialog to the user.
+     * @returns promise resolving to true if the user confirmed
+     */
+    showConfirmDialog: typeof dialogService.confirm;
+
+    /**
+     * Show prompt dialog to the user.
+     *
+     * @returns promise resolving to the answer provided by the user
+     */
+    showPromptDialog: typeof dialogService.prompt;
+
+    /**
+     * Trigger command. This is a very low-level API which should be avoided if possible.
+     */
+    triggerCommand(name: string, data: TriggerData): void;
+
+    /**
+     * Trigger event. This is a very low-level API which should be avoided if possible.
+     */
+    triggerEvent(name: string, data: TriggerData): void;
+
+     /**
+     * Create a note link (jQuery object) for given note.
+     *
+     * @param {string} notePath (or noteId)
+     * @param {object} [params]
+     * @param {boolean} [params.showTooltip] - enable/disable tooltip on the link
+     * @param {boolean} [params.showNotePath] - show also whole note's path as part of the link
+     * @param {boolean} [params.showNoteIcon] - show also note icon before the title
+     * @param {string} [params.title] - custom link tile with note's title as default
+     * @param {string} [params.title=] - custom link tile with note's title as default
+     * @returns {jQuery} - jQuery element with the link (wrapped in )
+     */
+    createLink: typeof linkService.createLink;
+
+    /** @deprecated - use api.createLink() instead */
+    createNoteLink: typeof linkService.createLink;
+
+    /**
+     * Adds given text to the editor cursor
+     *
+     * @param text - this must be clear text, HTML is not supported.
+     */
+    addTextToActiveContextEditor(text: string): void;
+
+    /**
+     * @returns active note (loaded into center pane)
+     */
+    getActiveContextNote(): FNote;
+
+    /**
+     * @returns returns active context (split)
+     */
+    getActiveContext(): NoteContext;
+
+    /**
+     * @returns returns active main context (first split in a tab, represents the tab as a whole)
+     */
+    getActiveMainContext(): NoteContext;
+
+    /**
+     * @returns returns all note contexts (splits) in all tabs
+     */
+    getNoteContexts(): NoteContext[];
+
+    /**
+     * @returns returns all main contexts representing tabs
+     */
+    getMainNoteContexts(): NoteContext[];
+
+    /**
+     * See https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html for documentation on the returned instance.
+     *
+     * @returns {Promise} instance of CKEditor
+     */
+    getActiveContextTextEditor(): Promise;
+
+    /**
+     * See https://codemirror.net/doc/manual.html#api
+     *
+     * @method
+     * @returns instance of CodeMirror
+     */
+    getActiveContextCodeEditor(): Promise;
+
+    /**
+     * Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
+     * implementation of actual widget type.
+     */
+    getActiveNoteDetailWidget(): Promise;
+    /**
+     * @returns returns a note path of active note or null if there isn't active note
+     */
+    getActiveContextNotePath(): string | null;
+
+    /**
+     * Returns component which owns the given DOM element (the nearest parent component in DOM tree)
+     *
+     * @method
+     * @param el DOM element
+     */
+    getComponentByEl(el: HTMLElement): Component;
+
+    /**
+     * @param {object} $el - jquery object on which to set up the tooltip
+     */
+    setupElementTooltip: typeof noteTooltipService.setupElementTooltip;
+
+    /**
+     * @param {boolean} protect - true to protect note, false to unprotect
+     */
+    protectNote: typeof protectedSessionService.protectNote;
+
+    /**
+     * @param noteId
+     * @param protect - true to protect subtree, false to unprotect
+     */
+    protectSubTree: typeof protectedSessionService.protectNote;
+
+    /**
+     * Returns date-note for today. If it doesn't exist, it is automatically created.
+     */
+    getTodayNote: typeof dateNotesService.getTodayNote;
+
+    /**
+     * Returns day note for a given date. If it doesn't exist, it is automatically created.
+     *
+     * @param date - e.g. "2019-04-29"
+     */
+    getDayNote: typeof dateNotesService.getDayNote;
+
+    /**
+     * Returns day note for the first date of the week of the given date. If it doesn't exist, it is automatically created.
+     *
+     * @param date - e.g. "2019-04-29"
+     */
+    getWeekNote: typeof dateNotesService.getWeekNote;
+
+    /**
+     * Returns month-note. If it doesn't exist, it is automatically created.
+     *
+     * @param month - e.g. "2019-04"
+     */
+    getMonthNote: typeof dateNotesService.getMonthNote;
+
+    /**
+     * Returns year-note. If it doesn't exist, it is automatically created.
+     *
+     * @method
+     * @param {string} year - e.g. "2019"
+     * @returns {Promise}
+     */
+    getYearNote: typeof dateNotesService.getYearNote;
+
+    /**
+     * Hoist note in the current tab. See https://triliumnext.github.io/Docs/Wiki/note-hoisting.html
+     *
+     * @param {string} noteId - set hoisted note. 'root' will effectively unhoist
+     */
+    setHoistedNoteId(noteId: string): void;
+
+     /**
+     * @param keyboardShortcut - e.g. "ctrl+shift+a"
+     * @param [namespace] specify namespace of the handler for the cases where call for bind may be repeated.
+     *                               If a handler with this ID exists, it's replaced by the new handler.
+     */
+    bindGlobalShortcut: typeof shortcutService.bindGlobalShortcut;
+
+    /**
+     * Trilium runs in a backend and frontend process, when something is changed on the backend from a script,
+     * frontend will get asynchronously synchronized.
+     *
+     * This method returns a promise which resolves once all the backend -> frontend synchronization is finished.
+     * Typical use case is when a new note has been created, we should wait until it is synced into frontend and only then activate it.
+     */
+    waitUntilSynced: typeof ws.waitForMaxKnownEntityChangeId;
+
+    /**
+     * This will refresh all currently opened notes which have included note specified in the parameter
+     *
+     * @param includedNoteId - noteId of the included note
+     */
+    refreshIncludedNote(includedNoteId: string): void;
+
+    /**
+     * Return randomly generated string of given length. This random string generation is NOT cryptographically secure.
+     *
+     * @method
+     * @param length of the string
+     * @returns random string
+     */
+    randomString: typeof utils.randomString;
+
+    /**
+     * @param size in bytes
+     * @return formatted string
+     */
+    formatSize: typeof utils.formatSize;
+
+    /**
+     * @param size in bytes
+     * @return formatted string
+     * @deprecated - use api.formatSize()
+     */
+    formatNoteSize: typeof utils.formatSize;
+
+    logMessages: Record;
+    logSpacedUpdates: Record;
+
+    /**
+     * Log given message to the log pane in UI
+     */
+    log(message: string): void;
+}
+
 /**
  * 

This is the main frontend API interface for scripts. All the properties and methods are published in the "api" object * available in the JS frontend notes. You can use e.g. api.showMessage(api.startNote.title);

* * @constructor */ -function FrontendScriptApi(startNote, currentNote, originEntity = null, $container = null) { - /** - * Container of all the rendered script content - * @type {jQuery} - * */ +function FrontendScriptApi (this: Api, startNote: FNote, currentNote: FNote, originEntity: Entity | null = null, $container = null) { + this.$container = $container; - - /** - * Note where the script started executing, i.e., the (event) entrypoint of the current script execution. - * @type {FNote} - */ this.startNote = startNote; - - /** - * Note where the script is currently executing, i.e. the note where the currently executing source code is written. - * @type {FNote} - */ - this.currentNote = currentNote; - - /** - * Entity whose event triggered this execution. - * @type {object|null} - */ + this.currentNote = currentNote; this.originEntity = originEntity; - - /** - * day.js library for date manipulation. - * See {@link https://day.js.org} for documentation - * @see https://day.js.org - * @type {dayjs} - */ this.dayjs = dayjs; - - /** @type {RightPanelWidget} */ this.RightPanelWidget = RightPanelWidget; - - /** @type {NoteContextAwareWidget} */ this.NoteContextAwareWidget = NoteContextAwareWidget; - - /** @type {BasicWidget} */ this.BasicWidget = BasicWidget; - - /** - * Activates note in the tree and in the note detail. - * - * @method - * @param {string} notePath (or noteId) - * @returns {Promise} - */ + this.activateNote = async notePath => { await appContext.tabManager.getActiveContext().setNote(notePath); }; - /** - * Activates newly created note. Compared to this.activateNote() also makes sure that frontend has been fully synced. - * - * @param {string} notePath (or noteId) - * @returns {Promise} - */ this.activateNewNote = async notePath => { await ws.waitForMaxKnownEntityChangeId(); @@ -100,14 +470,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain await appContext.triggerEvent('focusAndSelectTitle'); }; - /** - * Open a note in a new tab. - * - * @method - * @param {string} notePath (or noteId) - * @param {boolean} activate - set to true to activate the new tab, false to stay on the current tab - * @returns {Promise} - */ + this.openTabWithNote = async (notePath, activate) => { await ws.waitForMaxKnownEntityChangeId(); @@ -117,15 +480,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain await appContext.triggerEvent('focusAndSelectTitle'); } }; - - /** - * Open a note in a new split. - * - * @method - * @param {string} notePath (or noteId) - * @param {boolean} activate - set to true to activate the new split, false to stay on the current split - * @returns {Promise} - */ + this.openSplitWithNote = async (notePath, activate) => { await ws.waitForMaxKnownEntityChangeId(); @@ -138,31 +493,19 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain await appContext.triggerEvent('focusAndSelectTitle'); } }; - - /** - * Adds a new launcher to the launchbar. If the launcher (id) already exists, it will be updated. - * - * @method - * @deprecated you can now create/modify launchers in the top-left Menu -> Configure Launchbar - * for special needs there's also backend API's createOrUpdateLauncher() - * @param {object} opts - * @param {string} opts.title - * @param {function} opts.action - callback handling the click on the button - * @param {string} [opts.id] - id of the button, used to identify the old instances of this button to be replaced - * ID is optional because of BC, but not specifying it is deprecated. ID can be alphanumeric only. - * @param {string} [opts.icon] - name of the boxicon to be used (e.g. "time" for "bx-time" icon) - * @param {string} [opts.shortcut] - keyboard shortcut for the button, e.g. "alt+t" - */ + this.addButtonToToolbar = async opts => { console.warn("api.addButtonToToolbar() has been deprecated since v0.58 and may be removed in the future. Use Menu -> Configure Launchbar to create/update launchers instead."); const {action, ...reqBody} = opts; - reqBody.action = action.toString(); - - await server.put('special-notes/api-script-launcher', reqBody); + + await server.put('special-notes/api-script-launcher', { + action: action.toString(), + ...reqBody + }); }; - function prepareParams(params) { + function prepareParams(params: unknown[]) { if (!params) { return params; } @@ -177,15 +520,13 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain }); } - /** - * @private - */ + this.__runOnBackendInner = async (func, params, transactional) => { if (typeof func === "function") { func = func.toString(); } - const ret = await server.post('script/exec', { + const ret = await server.post('script/exec', { script: func, params: prepareParams(params), startNoteId: startNote.noteId, @@ -204,364 +545,91 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain } } - /** - * Executes given anonymous function on the backend. - * Internally this serializes the anonymous function into string and sends it to backend via AJAX. - * Please make sure that the supplied function is synchronous. Only sync functions will work correctly - * with transaction management. If you really know what you're doing, you can call api.runAsyncOnBackendWithManualTransactionHandling() - * - * @method - * @param {function|string} func - (synchronous) function to be executed on the backend - * @param {Array.} params - list of parameters to the anonymous function to be sent to backend - * @returns {Promise<*>} return value of the executed function on the backend - */ this.runOnBackend = async (func, params = []) => { - if (func?.constructor.name === "AsyncFunction" || func?.startsWith?.("async ")) { + if (func?.constructor.name === "AsyncFunction" || (typeof func === "string" && func?.startsWith?.("async "))) { toastService.showError(t("frontend_script_api.async_warning")); } return await this.__runOnBackendInner(func, params, true); }; - /** - * Executes given anonymous function on the backend. - * Internally this serializes the anonymous function into string and sends it to backend via AJAX. - * This function is meant for advanced needs where an async function is necessary. - * In this case, the automatic request-scoped transaction management is not applied, - * and you need to manually define transaction via api.transactional(). - * - * If you have a synchronous function, please use api.runOnBackend(). - * - * @method - * @param {function|string} func - (synchronous) function to be executed on the backend - * @param {Array.} params - list of parameters to the anonymous function to be sent to backend - * @returns {Promise<*>} return value of the executed function on the backend - */ + this.runAsyncOnBackendWithManualTransactionHandling = async (func, params = []) => { - if (func?.constructor.name === "Function" || func?.startsWith?.("function")) { + if (func?.constructor.name === "Function" || (typeof func === "string" && func?.startsWith?.("function"))) { toastService.showError(t("frontend_script_api.sync_warning")); } return await this.__runOnBackendInner(func, params, false); }; - - /** - * This is a powerful search method - you can search by attributes and their values, e.g.: - * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://triliumnext.github.io/Docs/Wiki/search.html - * - * @method - * @param {string} searchString - * @returns {Promise} - */ + this.searchForNotes = async searchString => { return await searchService.searchForNotes(searchString); }; - /** - * This is a powerful search method - you can search by attributes and their values, e.g.: - * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://triliumnext.github.io/Docs/Wiki/search.html - * - * @method - * @param {string} searchString - * @returns {Promise} - */ + this.searchForNote = async searchString => { const notes = await this.searchForNotes(searchString); return notes.length > 0 ? notes[0] : null; }; - /** - * Returns note by given noteId. If note is missing from the cache, it's loaded. - ** - * @method - * @param {string} noteId - * @returns {Promise} - */ + this.getNote = async noteId => await froca.getNote(noteId); - - /** - * Returns list of notes. If note is missing from the cache, it's loaded. - * - * This is often used to bulk-fill the cache with notes which would have to be picked one by one - * otherwise (by e.g. createLink()) - * - * @method - * @param {string[]} noteIds - * @param {boolean} [silentNotFoundError] - don't report error if the note is not found - * @returns {Promise} - */ - this.getNotes = async (noteIds, silentNotFoundError = false) => await froca.getNotes(noteIds, silentNotFoundError); - - /** - * Update frontend tree (note) cache from the backend. - * - * @method - * @param {string[]} noteIds - */ - this.reloadNotes = async noteIds => await froca.reloadNotes(noteIds); - - /** - * Instance name identifies particular Trilium instance. It can be useful for scripts - * if some action needs to happen on only one specific instance. - * - * @method - * @returns {string} - */ - this.getInstanceName = () => window.glob.instanceName; - - /** - * @method - * @param {Date} date - * @returns {string} date in YYYY-MM-DD format - */ + this.getNotes = async (noteIds, silentNotFoundError = false) => await froca.getNotes(noteIds, silentNotFoundError); + this.reloadNotes = async noteIds => await froca.reloadNotes(noteIds); + this.getInstanceName = () => window.glob.instanceName; this.formatDateISO = utils.formatDateISO; - - /** - * @method - * @param {string} str - * @returns {Date} parsed object - */ this.parseDate = utils.parseDate; - /** - * Show an info toast message to the user. - * - * @method - * @param {string} message - */ this.showMessage = toastService.showMessage; - - /** - * Show an error toast message to the user. - * - * @method - * @param {string} message - */ this.showError = toastService.showError; - - /** - * Show an info dialog to the user. - * - * @method - * @param {string} message - * @returns {Promise} - */ - this.showInfoDialog = dialogService.info; - - /** - * Show confirm dialog to the user. - * - * @method - * @param {string} message - * @returns {Promise} promise resolving to true if the user confirmed - */ + this.showInfoDialog = dialogService.info; this.showConfirmDialog = dialogService.confirm; - /** - * Show prompt dialog to the user. - * - * @method - * @param {object} props - * @param {string} props.title - * @param {string} props.message - * @param {string} props.defaultValue - * @returns {Promise} promise resolving to the answer provided by the user - */ + this.showPromptDialog = dialogService.prompt; - - /** - * Trigger command. This is a very low-level API which should be avoided if possible. - * - * @method - * @param {string} name - * @param {object} data - */ - this.triggerCommand = (name, data) => appContext.triggerCommand(name, data); - - /** - * Trigger event. This is a very low-level API which should be avoided if possible. - * - * @method - * @param {string} name - * @param {object} data - */ + + this.triggerCommand = (name, data) => appContext.triggerCommand(name, data); this.triggerEvent = (name, data) => appContext.triggerEvent(name, data); - /** - * Create a note link (jQuery object) for given note. - * - * @method - * @param {string} notePath (or noteId) - * @param {object} [params] - * @param {boolean} [params.showTooltip=true] - enable/disable tooltip on the link - * @param {boolean} [params.showNotePath=false] - show also whole note's path as part of the link - * @param {boolean} [params.showNoteIcon=false] - show also note icon before the title - * @param {string} [params.title] - custom link tile with note's title as default - * @param {string} [params.title=] - custom link tile with note's title as default - * @returns {jQuery} - jQuery element with the link (wrapped in ) - */ + this.createLink = linkService.createLink; - - /** @deprecated - use api.createLink() instead */ this.createNoteLink = linkService.createLink; - - /** - * Adds given text to the editor cursor - * - * @method - * @param {string} text - this must be clear text, HTML is not supported. - */ + this.addTextToActiveContextEditor = text => appContext.triggerCommand('addTextToActiveEditor', {text}); - /** - * @method - * @returns {FNote} active note (loaded into center pane) - */ this.getActiveContextNote = () => appContext.tabManager.getActiveContextNote(); - - /** - * @method - * @returns {NoteContext} - returns active context (split) - */ this.getActiveContext = () => appContext.tabManager.getActiveContext(); - - /** - * @method - * @returns {NoteContext} - returns active main context (first split in a tab, represents the tab as a whole) - */ this.getActiveMainContext = () => appContext.tabManager.getActiveMainContext(); - - /** - * @method - * @returns {NoteContext[]} - returns all note contexts (splits) in all tabs - */ - this.getNoteContexts = () => appContext.tabManager.getNoteContexts(); - - /** - * @method - * @returns {NoteContext[]} - returns all main contexts representing tabs - */ + + this.getNoteContexts = () => appContext.tabManager.getNoteContexts(); this.getMainNoteContexts = () => appContext.tabManager.getMainNoteContexts(); - /** - * See https://ckeditor.com/docs/ckeditor5/latest/api/module_core_editor_editor-Editor.html for documentation on the returned instance. - * - * @method - * @returns {Promise} instance of CKEditor - */ - this.getActiveContextTextEditor = () => appContext.tabManager.getActiveContext()?.getTextEditor(); - - /** - * See https://codemirror.net/doc/manual.html#api - * - * @method - * @returns {Promise} instance of CodeMirror - */ + + this.getActiveContextTextEditor = () => appContext.tabManager.getActiveContext()?.getTextEditor(); this.getActiveContextCodeEditor = () => appContext.tabManager.getActiveContext()?.getCodeEditor(); - - /** - * Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the - * implementation of actual widget type. - * - * @method - * @returns {Promise} - */ - this.getActiveNoteDetailWidget = () => new Promise(resolve => appContext.triggerCommand('executeInActiveNoteDetailWidget', {callback: resolve})); - - /** - * @method - * @returns {Promise} returns a note path of active note or null if there isn't active note - */ + + this.getActiveNoteDetailWidget = () => new Promise(resolve => appContext.triggerCommand('executeInActiveNoteDetailWidget', {callback: resolve})); this.getActiveContextNotePath = () => appContext.tabManager.getActiveContextNotePath(); - - /** - * Returns component which owns the given DOM element (the nearest parent component in DOM tree) - * - * @method - * @param {Element} el - DOM element - * @returns {Component} - */ + this.getComponentByEl = el => appContext.getComponentByEl(el); - - /** - * @method - * @param {object} $el - jquery object on which to set up the tooltip - * @returns {Promise} - */ + this.setupElementTooltip = noteTooltipService.setupElementTooltip; - - /** - * @method - * @param {string} noteId - * @param {boolean} protect - true to protect note, false to unprotect - * @returns {Promise} - */ + this.protectNote = async (noteId, protect) => { await protectedSessionService.protectNote(noteId, protect, false); }; - - /** - * @method - * @param {string} noteId - * @param {boolean} protect - true to protect subtree, false to unprotect - * @returns {Promise} - */ + this.protectSubTree = async (noteId, protect) => { await protectedSessionService.protectNote(noteId, protect, true); }; - - /** - * Returns date-note for today. If it doesn't exist, it is automatically created. - * - * @method - * @returns {Promise} - */ - this.getTodayNote = dateNotesService.getTodayNote; - - /** - * Returns day note for a given date. If it doesn't exist, it is automatically created. - * - * @method - * @param {string} date - e.g. "2019-04-29" - * @returns {Promise} - */ + + this.getTodayNote = dateNotesService.getTodayNote; this.getDayNote = dateNotesService.getDayNote; - - /** - * Returns day note for the first date of the week of the given date. If it doesn't exist, it is automatically created. - * - * @method - * @param {string} date - e.g. "2019-04-29" - * @returns {Promise} - */ - this.getWeekNote = dateNotesService.getWeekNote; - - /** - * Returns month-note. If it doesn't exist, it is automatically created. - * - * @method - * @param {string} month - e.g. "2019-04" - * @returns {Promise} - */ + this.getWeekNote = dateNotesService.getWeekNote; this.getMonthNote = dateNotesService.getMonthNote; - - /** - * Returns year-note. If it doesn't exist, it is automatically created. - * - * @method - * @param {string} year - e.g. "2019" - * @returns {Promise} - */ this.getYearNote = dateNotesService.getYearNote; - /** - * Hoist note in the current tab. See https://triliumnext.github.io/Docs/Wiki/note-hoisting.html - * - * @method - * @param {string} noteId - set hoisted note. 'root' will effectively unhoist - * @returns {Promise} - */ this.setHoistedNoteId = (noteId) => { const activeNoteContext = appContext.tabManager.getActiveContext(); @@ -570,69 +638,19 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain } }; - /** - * @method - * @param {string} keyboardShortcut - e.g. "ctrl+shift+a" - * @param {function} handler - * @param {string} [namespace] - specify namespace of the handler for the cases where call for bind may be repeated. - * If a handler with this ID exists, it's replaced by the new handler. - * @returns {Promise} - */ this.bindGlobalShortcut = shortcutService.bindGlobalShortcut; - - /** - * Trilium runs in a backend and frontend process, when something is changed on the backend from a script, - * frontend will get asynchronously synchronized. - * - * This method returns a promise which resolves once all the backend -> frontend synchronization is finished. - * Typical use case is when a new note has been created, we should wait until it is synced into frontend and only then activate it. - * - * @method - * @returns {Promise} - */ + this.waitUntilSynced = ws.waitForMaxKnownEntityChangeId; - - /** - * This will refresh all currently opened notes which have included note specified in the parameter - * - * @param includedNoteId - noteId of the included note - * @returns {Promise} - */ + this.refreshIncludedNote = includedNoteId => appContext.triggerEvent('refreshIncludedNote', {noteId: includedNoteId}); - /** - * Return randomly generated string of given length. This random string generation is NOT cryptographically secure. - * - * @method - * @param {int} length of the string - * @returns {string} random string - */ + this.randomString = utils.randomString; - - /** - * @method - * @param {int} size in bytes - * @return {string} formatted string - */ this.formatSize = utils.formatSize; - - /** - * @method - * @param {int} size in bytes - * @return {string} formatted string - * @deprecated - use api.formatSize() - */ this.formatNoteSize = utils.formatSize; this.logMessages = {}; - this.logSpacedUpdates = {}; - - /** - * Log given message to the log pane in UI - * - * @param message - * @returns {void} - */ + this.logSpacedUpdates = {}; this.log = message => { const {noteId} = this.startNote; diff --git a/src/public/app/services/spaced_update.ts b/src/public/app/services/spaced_update.ts index 4ac0fdd23..991320423 100644 --- a/src/public/app/services/spaced_update.ts +++ b/src/public/app/services/spaced_update.ts @@ -1,4 +1,4 @@ -type Callback = () => Promise; +type Callback = () => Promise | void; export default class SpacedUpdate { private updater: Callback; diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 4fb103b79..88f79b7eb 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -34,6 +34,7 @@ interface CustomGlobals { maxEntityChangeIdAtLoad: number; maxEntityChangeSyncIdAtLoad: number; assetPath: string; + instanceName: string; } type RequireMethod = (moduleName: string) => any; @@ -64,7 +65,7 @@ declare global { }; interface JQuery { - autocomplete: (action: "close" | "open" | "destroy" | AutoCompleteConfig, args?: AutoCompleteArg[]) => void; + autocomplete: (action: "close" | "open" | "destroy" | AutoCompleteConfig, args?: AutoCompleteArg[]) => void; } var logError: (message: string) => void; @@ -83,4 +84,5 @@ declare global { language: string }); }; + var dayjs: {}; } From 7c2002c589fb23ba3ac747cdca9e477c33b929ff Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 22:16:03 +0200 Subject: [PATCH 21/66] chore(client/ts): port services/script_context --- src/public/app/services/frontend_script_api.ts | 8 ++++---- .../{script_context.js => script_context.ts} | 12 ++++++++---- src/public/app/services/utils.ts | 4 ++-- 3 files changed, 14 insertions(+), 10 deletions(-) rename src/public/app/services/{script_context.js => script_context.ts} (70%) diff --git a/src/public/app/services/frontend_script_api.ts b/src/public/app/services/frontend_script_api.ts index b320518fe..905226d2d 100644 --- a/src/public/app/services/frontend_script_api.ts +++ b/src/public/app/services/frontend_script_api.ts @@ -445,10 +445,8 @@ interface Api { /** *

This is the main frontend API interface for scripts. All the properties and methods are published in the "api" object * available in the JS frontend notes. You can use e.g. api.showMessage(api.startNote.title);

- * - * @constructor */ -function FrontendScriptApi (this: Api, startNote: FNote, currentNote: FNote, originEntity: Entity | null = null, $container = null) { +function FrontendScriptApi(this: Api, startNote: FNote, currentNote: FNote, originEntity: Entity | null = null, $container: JQuery | null = null) { this.$container = $container; this.startNote = startNote; @@ -671,4 +669,6 @@ function FrontendScriptApi (this: Api, startNote: FNote, currentNote: FNote, ori }; } -export default FrontendScriptApi; +export default FrontendScriptApi as any as { + new (startNote: FNote, currentNote: FNote, originEntity: Entity | null, $container: JQuery | null): Api +}; diff --git a/src/public/app/services/script_context.js b/src/public/app/services/script_context.ts similarity index 70% rename from src/public/app/services/script_context.js rename to src/public/app/services/script_context.ts index f47380704..821f9aec0 100644 --- a/src/public/app/services/script_context.js +++ b/src/public/app/services/script_context.ts @@ -2,20 +2,24 @@ import FrontendScriptApi from './frontend_script_api.js'; import utils from './utils.js'; import froca from './froca.js'; -async function ScriptContext(startNoteId, allNoteIds, originEntity = null, $container = null) { - const modules = {}; +async function ScriptContext(startNoteId: string, allNoteIds: string[], originEntity = null, $container: JQuery | null = null) { + const modules: Record = {}; await froca.initializedPromise; const startNote = await froca.getNote(startNoteId); const allNotes = await froca.getNotes(allNoteIds); + if (!startNote) { + throw new Error(`Could not find start note ${startNoteId}.`); + } + return { modules: modules, notes: utils.toObject(allNotes, note => [note.noteId, note]), apis: utils.toObject(allNotes, note => [note.noteId, new FrontendScriptApi(startNote, note, originEntity, $container)]), - require: moduleNoteIds => { - return moduleName => { + require: (moduleNoteIds: string) => { + return (moduleName: string) => { const candidates = allNotes.filter(note => moduleNoteIds.includes(note.noteId)); const note = candidates.find(c => c.title === moduleName); diff --git a/src/public/app/services/utils.ts b/src/public/app/services/utils.ts index 4addc1d1f..6e2937beb 100644 --- a/src/public/app/services/utils.ts +++ b/src/public/app/services/utils.ts @@ -138,8 +138,8 @@ function formatSize(size: number) { } } -function toObject(array: T[], fn: (arg0: T) => [key: string, value: T]) { - const obj: Record = {}; +function toObject(array: T[], fn: (arg0: T) => [key: string, value: R]) { + const obj: Record = {}; for (const item of array) { const [key, value] = fn(item); From f3a7de58d5e655a4a794dbc067a8deddc52e69c6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 22:19:35 +0200 Subject: [PATCH 22/66] chore(client/ts): port services/resizer --- src/public/app/services/{resizer.js => resizer.ts} | 6 +++--- src/public/app/types.d.ts | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) rename src/public/app/services/{resizer.js => resizer.ts} (90%) diff --git a/src/public/app/services/resizer.js b/src/public/app/services/resizer.ts similarity index 90% rename from src/public/app/services/resizer.js rename to src/public/app/services/resizer.ts index 72c90de6c..6acea693a 100644 --- a/src/public/app/services/resizer.js +++ b/src/public/app/services/resizer.ts @@ -1,9 +1,9 @@ import options from "./options.js"; -let leftInstance; -let rightInstance; +let leftInstance: ReturnType | null; +let rightInstance: ReturnType | null; -function setupLeftPaneResizer(leftPaneVisible) { +function setupLeftPaneResizer(leftPaneVisible: boolean) { if (leftInstance) { leftInstance.destroy(); leftInstance = null; diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 88f79b7eb..9b65dcdca 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -85,4 +85,11 @@ declare global { }); }; var dayjs: {}; + var Split: (selectors: string[], config: { + sizes: [ number, number ]; + gutterSize: number; + onDragEnd: (sizes: [ number, number ]) => void; + }) => { + destroy(); + }; } From 214a71892da20ca7e3524a767d713db29e88d61f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 22:20:57 +0200 Subject: [PATCH 23/66] chore(client/ts): port services/render --- src/public/app/services/{render.js => render.ts} | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) rename src/public/app/services/{render.js => render.ts} (74%) diff --git a/src/public/app/services/render.js b/src/public/app/services/render.ts similarity index 74% rename from src/public/app/services/render.js rename to src/public/app/services/render.ts index e7ecf8d28..34e5fc901 100644 --- a/src/public/app/services/render.js +++ b/src/public/app/services/render.ts @@ -1,7 +1,12 @@ import server from "./server.js"; import bundleService from "./bundle.js"; +import FNote from "../entities/fnote.js"; -async function render(note, $el) { +interface Bundle { + html: string; +} + +async function render(note: FNote, $el: JQuery) { const relations = note.getRelations('renderNote'); const renderNoteIds = relations .map(rel => rel.value) @@ -10,7 +15,7 @@ async function render(note, $el) { $el.empty().toggle(renderNoteIds.length > 0); for (const renderNoteId of renderNoteIds) { - const bundle = await server.post(`script/bundle/${renderNoteId}`); + const bundle = await server.post(`script/bundle/${renderNoteId}`); const $scriptContainer = $('
'); $el.append($scriptContainer); From f7dc9ea8e4df6abd231d0fddb26867d59e897a4e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 22:25:48 +0200 Subject: [PATCH 24/66] chore(client/ts): port services/protected_session --- ...tected_session.js => protected_session.ts} | 25 ++++++++++++++----- src/public/app/services/toast.ts | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) rename src/public/app/services/{protected_session.js => protected_session.ts} (85%) diff --git a/src/public/app/services/protected_session.js b/src/public/app/services/protected_session.ts similarity index 85% rename from src/public/app/services/protected_session.js rename to src/public/app/services/protected_session.ts index e221e0d20..0b6b03c9d 100644 --- a/src/public/app/services/protected_session.js +++ b/src/public/app/services/protected_session.ts @@ -1,6 +1,7 @@ import server from './server.js'; import protectedSessionHolder from './protected_session_holder.js'; import toastService from "./toast.js"; +import type { ToastOptions } from "./toast.js"; import ws from "./ws.js"; import appContext from "../components/app_context.js"; import froca from "./froca.js"; @@ -8,7 +9,19 @@ import utils from "./utils.js"; import options from "./options.js"; import { t } from './i18n.js'; -let protectedSessionDeferred = null; +let protectedSessionDeferred: JQuery.Deferred | null = null; + +// TODO: Deduplicate with server when possible. +interface Response { + success: boolean; +} + +interface Message { + taskId: string; + data: { + protect: boolean + } +} async function leaveProtectedSession() { if (protectedSessionHolder.isProtectedSessionAvailable()) { @@ -44,11 +57,11 @@ async function reloadData() { await froca.loadInitialTree(); // make sure that all notes used in the application are loaded, including the ones not shown in the tree - await froca.reloadNotes(allNoteIds, true); + await froca.reloadNotes(allNoteIds); } -async function setupProtectedSession(password) { - const response = await server.post('login/protected', { password: password }); +async function setupProtectedSession(password: string) { + const response = await server.post('login/protected', { password: password }); if (!response.success) { toastService.showError(t("protected_session.wrong_password"), 3000); @@ -80,13 +93,13 @@ ws.subscribeToMessages(async message => { } }); -async function protectNote(noteId, protect, includingSubtree) { +async function protectNote(noteId: string, protect: boolean, includingSubtree: boolean) { await enterProtectedSession(); await server.put(`notes/${noteId}/protect/${protect ? 1 : 0}?subtree=${includingSubtree ? 1 : 0}`); } -function makeToast(message, title, text) { +function makeToast(message: Message, title: string, text: string): ToastOptions { return { id: message.taskId, title, diff --git a/src/public/app/services/toast.ts b/src/public/app/services/toast.ts index ba1cde259..9b6e4f64c 100644 --- a/src/public/app/services/toast.ts +++ b/src/public/app/services/toast.ts @@ -1,7 +1,7 @@ import ws from "./ws.js"; import utils from "./utils.js"; -interface ToastOptions { +export interface ToastOptions { id?: string; icon: string; title: string; From 4505564f1374739bc68f0a109a07da304ff961e9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 19 Dec 2024 22:29:03 +0200 Subject: [PATCH 25/66] chore(client/ts): port services/note_types --- .../services/{note_types.js => note_types.ts} | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) rename src/public/app/services/{note_types.js => note_types.ts} (84%) diff --git a/src/public/app/services/note_types.js b/src/public/app/services/note_types.ts similarity index 84% rename from src/public/app/services/note_types.js rename to src/public/app/services/note_types.ts index 8bc30af6b..2526821a8 100644 --- a/src/public/app/services/note_types.js +++ b/src/public/app/services/note_types.ts @@ -2,8 +2,18 @@ import server from "./server.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; -async function getNoteTypeItems(command) { - const items = [ +type NoteTypeItem = { + title: string; + command: string; + type: string; + uiIcon: string; + templateNoteId?: string; +} | { + title: "----" +}; + +async function getNoteTypeItems(command: string) { + const items: NoteTypeItem[] = [ { title: t("note_types.text"), command: command, type: "text", uiIcon: "bx bx-note" }, { title: t("note_types.code"), command: command, type: "code", uiIcon: "bx bx-code" }, { title: t("note_types.saved-search"), command: command, type: "search", uiIcon: "bx bx-file-find" }, @@ -17,7 +27,7 @@ async function getNoteTypeItems(command) { { title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" } ]; - const templateNoteIds = await server.get("search-templates"); + const templateNoteIds = await server.get("search-templates"); const templateNotes = await froca.getNotes(templateNoteIds); if (templateNotes.length > 0) { From ac75e724910c73706b19e0af0266e74bc9a1cb34 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 09:26:37 +0200 Subject: [PATCH 26/66] chore(client/ts): port bulk_actions/abstract_bulk_action --- ...bulk_action.js => abstract_bulk_action.ts} | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) rename src/public/app/widgets/bulk_actions/{abstract_bulk_action.js => abstract_bulk_action.ts} (71%) diff --git a/src/public/app/widgets/bulk_actions/abstract_bulk_action.js b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts similarity index 71% rename from src/public/app/widgets/bulk_actions/abstract_bulk_action.js rename to src/public/app/widgets/bulk_actions/abstract_bulk_action.ts index 634f3d073..1ce81e190 100644 --- a/src/public/app/widgets/bulk_actions/abstract_bulk_action.js +++ b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts @@ -2,9 +2,17 @@ import { t } from "../../services/i18n.js"; import server from "../../services/server.js"; import ws from "../../services/ws.js"; import utils from "../../services/utils.js"; +import FAttribute from "../../entities/fattribute.js"; -export default class AbstractBulkAction { - constructor(attribute, actionDef) { +interface ActionDefinition { + +} + +export default abstract class AbstractBulkAction { + attribute: FAttribute; + actionDef: ActionDefinition; + + constructor(attribute: FAttribute, actionDef: ActionDefinition) { this.attribute = attribute; this.actionDef = actionDef; } @@ -20,18 +28,18 @@ export default class AbstractBulkAction { utils.initHelpDropdown($rendered); return $rendered; - } - catch (e) { + } catch (e: any) { logError(`Failed rendering search action: ${JSON.stringify(this.attribute.dto)} with error: ${e.message} ${e.stack}`); return null; } } // to be overridden - doRender() {} + abstract doRender(): JQuery; + abstract get actionName(): string; - async saveAction(data) { - const actionObject = Object.assign({ name: this.constructor.actionName }, data); + async saveAction(data: {}) { + const actionObject = Object.assign({ name: (this.constructor as unknown as AbstractBulkAction).actionName }, data); await server.put(`notes/${this.attribute.noteId}/attribute`, { attributeId: this.attribute.attributeId, From 934a395f15415490f91441bc891be7bcfe3ad210 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 09:29:50 +0200 Subject: [PATCH 27/66] chore(client/ts): port bulk_actions/execute_script --- src/public/app/widgets/bulk_actions/abstract_bulk_action.ts | 6 +++--- .../bulk_actions/{execute_script.js => execute_script.ts} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename src/public/app/widgets/bulk_actions/{execute_script.js => execute_script.ts} (100%) diff --git a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts index 1ce81e190..77bb9a3d6 100644 --- a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts +++ b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts @@ -5,7 +5,7 @@ import utils from "../../services/utils.js"; import FAttribute from "../../entities/fattribute.js"; interface ActionDefinition { - + script: string; } export default abstract class AbstractBulkAction { @@ -36,10 +36,10 @@ export default abstract class AbstractBulkAction { // to be overridden abstract doRender(): JQuery; - abstract get actionName(): string; + static get actionName() { return ""; } async saveAction(data: {}) { - const actionObject = Object.assign({ name: (this.constructor as unknown as AbstractBulkAction).actionName }, data); + const actionObject = Object.assign({ name: (this.constructor as typeof AbstractBulkAction).actionName }, data); await server.put(`notes/${this.attribute.noteId}/attribute`, { attributeId: this.attribute.attributeId, diff --git a/src/public/app/widgets/bulk_actions/execute_script.js b/src/public/app/widgets/bulk_actions/execute_script.ts similarity index 100% rename from src/public/app/widgets/bulk_actions/execute_script.js rename to src/public/app/widgets/bulk_actions/execute_script.ts From e889955e8b85cff8848f6829e7756e244a15ba67 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 14:34:16 +0200 Subject: [PATCH 28/66] chore(client/ts): port services/note_autocomplete --- src/public/app/components/app_context.ts | 3 + src/public/app/services/froca.ts | 2 +- ...e_autocomplete.js => note_autocomplete.ts} | 61 +++++++++++++------ src/public/app/types.d.ts | 31 +++++++--- 4 files changed, 70 insertions(+), 27 deletions(-) rename src/public/app/services/{note_autocomplete.js => note_autocomplete.ts} (83%) diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 783373023..debc0a132 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -40,6 +40,9 @@ export type TriggerData = { text: string; } | { callback: (value: NoteDetailWidget | PromiseLike) => void +} | { + // For "searchNotes" + searchString: string | undefined; } class AppContext extends Component { diff --git a/src/public/app/services/froca.ts b/src/public/app/services/froca.ts index 5bc1191e3..f84dc37ea 100644 --- a/src/public/app/services/froca.ts +++ b/src/public/app/services/froca.ts @@ -243,7 +243,7 @@ class FrocaImpl implements Froca { }).filter(note => !!note) as FNote[]; } - async getNotes(noteIds: string[], silentNotFoundError = false): Promise { + async getNotes(noteIds: string[] | JQuery, silentNotFoundError = false): Promise { noteIds = Array.from(new Set(noteIds)); // make unique const missingNoteIds = noteIds.filter(noteId => !this.notes[noteId]); diff --git a/src/public/app/services/note_autocomplete.js b/src/public/app/services/note_autocomplete.ts similarity index 83% rename from src/public/app/services/note_autocomplete.js rename to src/public/app/services/note_autocomplete.ts index 7fb29de46..0fa39ed2a 100644 --- a/src/public/app/services/note_autocomplete.js +++ b/src/public/app/services/note_autocomplete.ts @@ -10,7 +10,26 @@ const SELECTED_NOTE_PATH_KEY = "data-note-path"; const SELECTED_EXTERNAL_LINK_KEY = "data-external-link"; -async function autocompleteSourceForCKEditor(queryText) { +export interface Suggestion { + noteTitle?: string; + externalLink?: string; + notePathTitle?: string; + notePath?: string; + highlightedNotePathTitle?: string; + action?: string | "create-note" | "search-notes" | "external-link"; + parentNoteId?: string; +} + +interface Options { + container?: HTMLElement; + fastSearch?: boolean; + allowCreatingNotes?: boolean; + allowJumpToSearchNotes?: boolean; + allowExternalLinks?: boolean; + hideGoToSelectedNoteButton?: boolean; +} + +async function autocompleteSourceForCKEditor(queryText: string) { return await new Promise((res, rej) => { autocompleteSource(queryText, rows => { res(rows.map(row => { @@ -30,7 +49,7 @@ async function autocompleteSourceForCKEditor(queryText) { }); } -async function autocompleteSource(term, cb, options = {}) { +async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) { const fastSearch = options.fastSearch === false ? false : true; if (fastSearch === false) { if (term.trim().length === 0){ @@ -46,7 +65,7 @@ async function autocompleteSource(term, cb, options = {}) { const activeNoteId = appContext.tabManager.getActiveContextNoteId(); - let results = await server.get(`autocomplete?query=${encodeURIComponent(term)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`); + let results: Suggestion[] = await server.get(`autocomplete?query=${encodeURIComponent(term)}&activeNoteId=${activeNoteId}&fastSearch=${fastSearch}`); if (term.trim().length >= 1 && options.allowCreatingNotes) { results = [ { @@ -54,7 +73,7 @@ async function autocompleteSource(term, cb, options = {}) { noteTitle: term, parentNoteId: activeNoteId || 'root', highlightedNotePathTitle: t("note_autocomplete.create-note", { term }) - } + } as Suggestion ].concat(results); } @@ -74,14 +93,14 @@ async function autocompleteSource(term, cb, options = {}) { action: 'external-link', externalLink: term, highlightedNotePathTitle: t("note_autocomplete.insert-external-link", { term }) - } + } as Suggestion ].concat(results); } cb(results); } -function clearText($el) { +function clearText($el: JQuery) { if (utils.isMobile()) { return; } @@ -90,7 +109,7 @@ function clearText($el) { $el.autocomplete("val", "").trigger('change'); } -function setText($el, text) { +function setText($el: JQuery, text: string) { if (utils.isMobile()) { return; } @@ -101,7 +120,7 @@ function setText($el, text) { .autocomplete("open"); } -function showRecentNotes($el) { +function showRecentNotes($el:JQuery) { if (utils.isMobile()) { return; } @@ -112,21 +131,22 @@ function showRecentNotes($el) { $el.trigger('focus'); } -function fullTextSearch($el, options){ - const searchString = $el.autocomplete('val'); - if (options.fastSearch === false || searchString.trim().length === 0) { +function fullTextSearch($el: JQuery, options: Options){ + const searchString = $el.autocomplete('val') as unknown as string; + if (options.fastSearch === false || searchString?.trim().length === 0) { return; } $el.trigger('focus'); options.fastSearch = false; $el.autocomplete('val', ''); + $el.autocomplete() $el.setSelectedNotePath(""); $el.autocomplete('val', searchString); // Set a delay to avoid resetting to true before full text search (await server.get) is called. setTimeout(() => { options.fastSearch = true; }, 100); } -function initNoteAutocomplete($el, options) { +function initNoteAutocomplete($el: JQuery, options: Options) { if ($el.hasClass("note-autocomplete-input") || utils.isMobile()) { // clear any event listener added in previous invocation of this function $el.off('autocomplete:noteselected'); @@ -174,7 +194,7 @@ function initNoteAutocomplete($el, options) { return false; }); - let autocompleteOptions = {}; + let autocompleteOptions: AutoCompleteConfig = {}; if (options.container) { autocompleteOptions.dropdownMenuContainer = options.container; autocompleteOptions.debug = true; // don't close on blur @@ -221,7 +241,8 @@ function initNoteAutocomplete($el, options) { } ]); - $el.on('autocomplete:selected', async (event, suggestion) => { + // TODO: Types fail due to "autocomplete:selected" not being registered in type definitions. + ($el as any).on('autocomplete:selected', async (event: Event, suggestion: Suggestion) => { if (suggestion.action === 'external-link') { $el.setSelectedNotePath(null); $el.setSelectedExternalLink(suggestion.externalLink); @@ -250,7 +271,7 @@ function initNoteAutocomplete($el, options) { }); const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; - suggestion.notePath = note.getBestNotePathString(hoistedNoteId); + suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); } if (suggestion.action === 'search-notes') { @@ -270,7 +291,7 @@ function initNoteAutocomplete($el, options) { }); $el.on('autocomplete:closed', () => { - if (!$el.val().trim()) { + if (!String($el.val())?.trim()) { clearText($el); } }); @@ -289,7 +310,7 @@ function initNoteAutocomplete($el, options) { function init() { $.fn.getSelectedNotePath = function () { - if (!$(this).val().trim()) { + if (!String($(this).val())?.trim()) { return ""; } else { return $(this).attr(SELECTED_NOTE_PATH_KEY); @@ -297,7 +318,8 @@ function init() { }; $.fn.getSelectedNoteId = function () { - const notePath = $(this).getSelectedNotePath(); + const $el = $(this as unknown as HTMLElement); + const notePath = $el.getSelectedNotePath(); if (!notePath) { return null; } @@ -320,7 +342,7 @@ function init() { }; $.fn.getSelectedExternalLink = function () { - if (!$(this).val().trim()) { + if (!String($(this).val())?.trim()) { return ""; } else { return $(this).attr(SELECTED_EXTERNAL_LINK_KEY); @@ -329,6 +351,7 @@ function init() { $.fn.setSelectedExternalLink = function (externalLink) { if (externalLink) { + // TODO: This doesn't seem to do anything with the external link, is it normal? $(this) .closest(".input-group") .find(".go-to-selected-note-button") diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 9b65dcdca..e601c47e0 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -2,6 +2,7 @@ import type FNote from "./entities/fnote"; import type { BackendModule, i18n } from "i18next"; import type { Froca } from "./services/froca-interface"; import type { HttpBackendOptions } from "i18next-http-backend"; +import { Suggestion } from "./services/note_autocomplete.ts"; interface ElectronProcess { type: string; @@ -42,7 +43,7 @@ type RequireMethod = (moduleName: string) => any; declare global { interface Window { logError(message: string); - logInfo(message: string); + logInfo(message: string); process?: ElectronProcess; glob?: CustomGlobals; @@ -53,23 +54,36 @@ declare global { hint?: boolean; openOnFocus?: boolean; minLength?: number; - tabAutocomplete?: boolean + tabAutocomplete?: boolean; + autoselect?: boolean; + dropdownMenuContainer?: HTMLElement; + debug?: boolean; } - type AutoCompleteCallback = (values: AutoCompleteCallbackArgs[]) => void; + type AutoCompleteCallback = (values: AutoCompleteCallbackArg[]) => void; interface AutoCompleteArg { - displayKey: "name" | "value"; + displayKey: "name" | "value" | "notePathTitle"; cache: boolean; - source: (term: string, cb: AutoCompleteCallback) => void + source: (term: string, cb: AutoCompleteCallback) => void, + templates: { + suggestion: (suggestion: Suggestion) => string | undefined + } }; interface JQuery { - autocomplete: (action: "close" | "open" | "destroy" | AutoCompleteConfig, args?: AutoCompleteArg[]) => void; + autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: AutoCompleteArg[] | string) => JQuery; + + getSelectedNotePath(): string | undefined; + getSelectedNoteId(): string | null; + setSelectedNotePath(notePath: string | null | undefined); + getSelectedExternalLink(this: HTMLElement): string | undefined; + setSelectedExternalLink(externalLink: string | null | undefined); + setNote(noteId: string); } var logError: (message: string) => void; - var logInfo: (message: string) => void; + var logInfo: (message: string) => void; var glob: CustomGlobals; var require: RequireMethod; var __non_webpack_require__: RequireMethod | undefined; @@ -92,4 +106,7 @@ declare global { }) => { destroy(); }; + var renderMathInElement: (element: HTMLElement, options: { + trust: boolean; + }) => void; } From 6f32f21ac4a2e39a7835bb672a1e1ae5b23affcf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 14:38:25 +0200 Subject: [PATCH 29/66] chore(client/ts): port services/note_tooltip --- .../{note_tooltip.js => note_tooltip.ts} | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) rename src/public/app/services/{note_tooltip.js => note_tooltip.ts} (87%) diff --git a/src/public/app/services/note_tooltip.js b/src/public/app/services/note_tooltip.ts similarity index 87% rename from src/public/app/services/note_tooltip.js rename to src/public/app/services/note_tooltip.ts index 3aecc7d8e..be0f2dd80 100644 --- a/src/public/app/services/note_tooltip.js +++ b/src/public/app/services/note_tooltip.ts @@ -5,6 +5,7 @@ import utils from "./utils.js"; import attributeRenderer from "./attribute_renderer.js"; import contentRenderer from "./content_renderer.js"; import appContext from "../components/app_context.js"; +import FNote from "../entities/fnote.js"; function setupGlobalTooltip() { $(document).on("mouseenter", "a", mouseEnterHandler); @@ -24,11 +25,11 @@ function cleanUpTooltips() { $('.note-tooltip').remove(); } -function setupElementTooltip($el) { +function setupElementTooltip($el: JQuery) { $el.on('mouseenter', mouseEnterHandler); } -async function mouseEnterHandler() { +async function mouseEnterHandler(this: HTMLElement) { const $link = $(this); if ($link.hasClass("no-tooltip-preview") || $link.hasClass("disabled")) { @@ -44,7 +45,7 @@ async function mouseEnterHandler() { const url = $link.attr("href") || $link.attr("data-href"); const { notePath, noteId, viewScope } = linkService.parseNavigationStateFromUrl(url); - if (!notePath || viewScope.viewMode !== 'default') { + if (!notePath || !noteId || viewScope?.viewMode !== 'default') { return; } @@ -64,7 +65,7 @@ async function mouseEnterHandler() { new Promise(res => setTimeout(res, 500)) ]); - if (utils.isHtmlEmpty(content)) { + if (!content || utils.isHtmlEmpty(content)) { return; } @@ -81,7 +82,8 @@ async function mouseEnterHandler() { // with bottom this flickering happens a bit less placement: 'bottom', trigger: 'manual', - boundary: 'window', + //TODO: boundary No longer applicable? + //boundary: 'window', title: html, html: true, template: ``, @@ -114,7 +116,7 @@ async function mouseEnterHandler() { } } -async function renderTooltip(note) { +async function renderTooltip(note: FNote | null) { if (!note) { return '
Note has been deleted.
'; } @@ -126,7 +128,11 @@ async function renderTooltip(note) { return; } - let content = `
${(await treeService.getNoteTitleWithPathAsSuffix(bestNotePath)).prop('outerHTML')}
`; + const noteTitleWithPathAsSuffix = await treeService.getNoteTitleWithPathAsSuffix(bestNotePath); + let content = ""; + if (noteTitleWithPathAsSuffix) { + content = `
${noteTitleWithPathAsSuffix.prop('outerHTML')}
`; + } const {$renderedAttributes} = await attributeRenderer.renderNormalAttributes(note); From 27ed750d484bbb565d4201dda4dab45e506bc198 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 14:39:36 +0200 Subject: [PATCH 30/66] chore(client/ts): port bulk_actions/add_relation --- src/public/app/services/note_autocomplete.ts | 2 +- src/public/app/widgets/bulk_actions/abstract_bulk_action.ts | 2 ++ .../bulk_actions/relation/{add_relation.js => add_relation.ts} | 0 3 files changed, 3 insertions(+), 1 deletion(-) rename src/public/app/widgets/bulk_actions/relation/{add_relation.js => add_relation.ts} (100%) diff --git a/src/public/app/services/note_autocomplete.ts b/src/public/app/services/note_autocomplete.ts index 0fa39ed2a..d83d83f62 100644 --- a/src/public/app/services/note_autocomplete.ts +++ b/src/public/app/services/note_autocomplete.ts @@ -146,7 +146,7 @@ function fullTextSearch($el: JQuery, options: Options){ setTimeout(() => { options.fastSearch = true; }, 100); } -function initNoteAutocomplete($el: JQuery, options: Options) { +function initNoteAutocomplete($el: JQuery, options?: Options) { if ($el.hasClass("note-autocomplete-input") || utils.isMobile()) { // clear any event listener added in previous invocation of this function $el.off('autocomplete:noteselected'); diff --git a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts index 77bb9a3d6..07fd221d4 100644 --- a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts +++ b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts @@ -6,6 +6,8 @@ import FAttribute from "../../entities/fattribute.js"; interface ActionDefinition { script: string; + relationName: string; + targetNoteId: string; } export default abstract class AbstractBulkAction { diff --git a/src/public/app/widgets/bulk_actions/relation/add_relation.js b/src/public/app/widgets/bulk_actions/relation/add_relation.ts similarity index 100% rename from src/public/app/widgets/bulk_actions/relation/add_relation.js rename to src/public/app/widgets/bulk_actions/relation/add_relation.ts From ef4d2378f1d01dc196092702161d219d6e23e361 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 14:56:51 +0200 Subject: [PATCH 31/66] chore(client/ts): port services/content_renderer --- src/public/app/entities/fattachment.ts | 7 ++- ...ontent_renderer.js => content_renderer.ts} | 60 ++++++++++--------- src/public/app/types.d.ts | 18 ++++++ 3 files changed, 54 insertions(+), 31 deletions(-) rename src/public/app/services/{content_renderer.js => content_renderer.ts} (84%) diff --git a/src/public/app/entities/fattachment.ts b/src/public/app/entities/fattachment.ts index 704a53ba7..20ba79c94 100644 --- a/src/public/app/entities/fattachment.ts +++ b/src/public/app/entities/fattachment.ts @@ -21,10 +21,11 @@ class FAttachment { attachmentId!: string; private ownerId!: string; role!: string; - private mime!: string; - private title!: string; + mime!: string; + title!: string; + isProtected!: boolean; // TODO: Is this used? private dateModified!: string; - private utcDateModified!: string; + utcDateModified!: string; private utcDateScheduledForErasureSince!: string; /** * optionally added to the entity diff --git a/src/public/app/services/content_renderer.js b/src/public/app/services/content_renderer.ts similarity index 84% rename from src/public/app/services/content_renderer.js rename to src/public/app/services/content_renderer.ts index ec0bee7f5..2ff796b4c 100644 --- a/src/public/app/services/content_renderer.js +++ b/src/public/app/services/content_renderer.ts @@ -16,12 +16,13 @@ import { loadElkIfNeeded } from "./mermaid.js"; let idCounter = 1; -/** - * @param {FNote|FAttachment} entity - * @param {object} options - * @return {Promise<{type: string, $renderedContent: jQuery}>} - */ -async function getRenderedContent(entity, options = {}) { +interface Options { + tooltip?: boolean; + trim?: boolean; + imageHasZoom?: boolean; +} + +async function getRenderedContent(this: {} | { ctx: string }, entity: FNote, options: Options = {}) { options = Object.assign({ tooltip: false }, options); @@ -49,7 +50,7 @@ async function getRenderedContent(entity, options = {}) { else if (type === 'render') { const $content = $('
'); - await renderService.render(entity, $content, this.ctx); + await renderService.render(entity, $content); $renderedContent.append($content); } @@ -86,12 +87,11 @@ async function getRenderedContent(entity, options = {}) { }; } -/** @param {FNote} note */ -async function renderText(note, $renderedContent) { +async function renderText(note: FNote, $renderedContent: JQuery) { // entity must be FNote const blob = await note.getBlob(); - if (!utils.isHtmlEmpty(blob.content)) { + if (blob && !utils.isHtmlEmpty(blob.content)) { $renderedContent.append($('
').html(blob.content)); if ($renderedContent.find('span.math-tex').length > 0) { @@ -100,9 +100,9 @@ async function renderText(note, $renderedContent) { renderMathInElement($renderedContent[0], {trust: true}); } - const getNoteIdFromLink = el => treeService.getNoteIdFromUrl($(el).attr('href')); + const getNoteIdFromLink = (el: HTMLElement) => treeService.getNoteIdFromUrl($(el).attr('href') || ""); const referenceLinks = $renderedContent.find("a.reference-link"); - const noteIdsToPrefetch = referenceLinks.map(el => getNoteIdFromLink(el)); + const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el)); await froca.getNotes(noteIdsToPrefetch); for (const el of referenceLinks) { @@ -117,19 +117,17 @@ async function renderText(note, $renderedContent) { /** * Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type. - * - * @param {FNote} note */ -async function renderCode(note, $renderedContent) { +async function renderCode(note: FNote, $renderedContent: JQuery) { const blob = await note.getBlob(); const $codeBlock = $(""); - $codeBlock.text(blob.content); + $codeBlock.text(blob?.content || ""); $renderedContent.append($("
").append($codeBlock));
     await applySingleBlockSyntaxHighlight($codeBlock, mime_types.normalizeMimeTypeForCKEditor(note.mime));
 }
 
-function renderImage(entity, $renderedContent, options = {}) {
+function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery, options: Options = {}) {
     const encodedTitle = encodeURIComponent(entity.title);
 
     let url;
@@ -146,7 +144,7 @@ function renderImage(entity, $renderedContent, options = {}) {
         .css('justify-content', 'center');
 
     const $img = $("")
-        .attr("src", url)
+        .attr("src", url || "")
         .attr("id", "attachment-image-" + idCounter++)
         .css("max-width", "100%");
 
@@ -165,7 +163,7 @@ function renderImage(entity, $renderedContent, options = {}) {
     imageContextMenuService.setupContextMenu($img);
 }
 
-function renderFile(entity, type, $renderedContent) {
+function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: JQuery) {
     let entityType, entityId;
 
     if (entity instanceof FNote) {
@@ -201,7 +199,7 @@ function renderFile(entity, type, $renderedContent) {
         $content.append($videoPreview);
     }
 
-    if (entityType === 'notes') {
+    if (entityType === 'notes' && "noteId" in entity) {
         // TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
         //       in attachment list
         const $downloadButton = $('');
@@ -222,11 +220,11 @@ function renderFile(entity, type, $renderedContent) {
     $renderedContent.append($content);
 }
 
-async function renderMermaid(note, $renderedContent) {
+async function renderMermaid(note: FNote, $renderedContent: JQuery) {
     await libraryLoader.requireLibrary(libraryLoader.MERMAID);
 
     const blob = await note.getBlob();
-    const content = blob.content || "";
+    const content = blob?.content || "";
 
     $renderedContent
         .css("display", "flex")
@@ -254,7 +252,7 @@ async function renderMermaid(note, $renderedContent) {
  * @param {FNote} note
  * @returns {Promise}
  */
-async function renderChildrenList($renderedContent, note) {
+async function renderChildrenList($renderedContent: JQuery, note: FNote) {
     $renderedContent.css("padding", "10px");
     $renderedContent.addClass("text-with-ellipsis");
 
@@ -277,15 +275,21 @@ async function renderChildrenList($renderedContent, note) {
     }
 }
 
-function getRenderingType(entity) {
-    let type = entity.type || entity.role;
-    const mime = entity.mime;
+function getRenderingType(entity: FNote | FAttachment) {
+    let type: string = "";
+    if ("type" in entity) {
+        type = entity.type;
+    } else if ("role" in entity) {
+        type = entity.role;
+    }
+
+    const mime = ("mime" in entity && entity.mime);
 
     if (type === 'file' && mime === 'application/pdf') {
         type = 'pdf';
-    } else if (type === 'file' && mime.startsWith('audio/')) {
+    } else if (type === 'file' && mime && mime.startsWith('audio/')) {
         type = 'audio';
-    } else if (type === 'file' && mime.startsWith('video/')) {
+    } else if (type === 'file' && mime && mime.startsWith('video/')) {
         type = 'video';
     }
 
diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts
index e601c47e0..13c506091 100644
--- a/src/public/app/types.d.ts
+++ b/src/public/app/types.d.ts
@@ -109,4 +109,22 @@ declare global {
     var renderMathInElement: (element: HTMLElement, options: {
         trust: boolean;
     }) => void;
+    var WZoom = {
+        create(selector: string, opts: {
+            maxScale: number;
+            speed: number;
+            zoomOnClick: boolean
+        })
+    };
+    interface MermaidApi {
+        initialize(opts: {
+            startOnLoad: boolean,
+            theme: string,
+            securityLevel: "antiscript"
+        }): void;
+        render(selector: string, data: string);
+    }
+    var mermaid: {        
+        mermaidAPI: MermaidApi;
+    };
 }

From 5bdb325e08edec9f3ae0477658d86e3a4a0d2753 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 15:03:45 +0200
Subject: [PATCH 32/66] chore(client/ts): port bulk_actions/relations

---
 src/public/app/widgets/bulk_actions/abstract_bulk_action.ts     | 2 ++
 .../relation/{delete_relation.js => delete_relation.ts}         | 0
 .../relation/{rename_relation.js => rename_relation.ts}         | 0
 .../{update_relation_target.js => update_relation_target.ts}    | 0
 4 files changed, 2 insertions(+)
 rename src/public/app/widgets/bulk_actions/relation/{delete_relation.js => delete_relation.ts} (100%)
 rename src/public/app/widgets/bulk_actions/relation/{rename_relation.js => rename_relation.ts} (100%)
 rename src/public/app/widgets/bulk_actions/relation/{update_relation_target.js => update_relation_target.ts} (100%)

diff --git a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts
index 07fd221d4..df55051f1 100644
--- a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts
+++ b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts
@@ -8,6 +8,8 @@ interface ActionDefinition {
     script: string;
     relationName: string;
     targetNoteId: string;
+    oldRelationName?: string;
+    newRelationName?: string;
 }
 
 export default abstract class AbstractBulkAction {
diff --git a/src/public/app/widgets/bulk_actions/relation/delete_relation.js b/src/public/app/widgets/bulk_actions/relation/delete_relation.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/relation/delete_relation.js
rename to src/public/app/widgets/bulk_actions/relation/delete_relation.ts
diff --git a/src/public/app/widgets/bulk_actions/relation/rename_relation.js b/src/public/app/widgets/bulk_actions/relation/rename_relation.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/relation/rename_relation.js
rename to src/public/app/widgets/bulk_actions/relation/rename_relation.ts
diff --git a/src/public/app/widgets/bulk_actions/relation/update_relation_target.js b/src/public/app/widgets/bulk_actions/relation/update_relation_target.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/relation/update_relation_target.js
rename to src/public/app/widgets/bulk_actions/relation/update_relation_target.ts

From b14cb4e3cef7dd76d1baedec2f9154776288e147 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 15:04:33 +0200
Subject: [PATCH 33/66] chore(client/ts): port bulk_actions/note

---
 src/public/app/widgets/bulk_actions/abstract_bulk_action.ts     | 2 ++
 .../bulk_actions/note/{delete_note.js => delete_note.ts}        | 0
 .../note/{delete_revisions.js => delete_revisions.ts}           | 0
 .../widgets/bulk_actions/note/{move_note.js => move_note.ts}    | 0
 .../bulk_actions/note/{rename_note.js => rename_note.ts}        | 0
 5 files changed, 2 insertions(+)
 rename src/public/app/widgets/bulk_actions/note/{delete_note.js => delete_note.ts} (100%)
 rename src/public/app/widgets/bulk_actions/note/{delete_revisions.js => delete_revisions.ts} (100%)
 rename src/public/app/widgets/bulk_actions/note/{move_note.js => move_note.ts} (100%)
 rename src/public/app/widgets/bulk_actions/note/{rename_note.js => rename_note.ts} (100%)

diff --git a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts
index df55051f1..dcde6a74d 100644
--- a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts
+++ b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts
@@ -8,8 +8,10 @@ interface ActionDefinition {
     script: string;
     relationName: string;
     targetNoteId: string;
+    targetParentNoteId: string;
     oldRelationName?: string;
     newRelationName?: string;
+    newTitle?: string;
 }
 
 export default abstract class AbstractBulkAction {
diff --git a/src/public/app/widgets/bulk_actions/note/delete_note.js b/src/public/app/widgets/bulk_actions/note/delete_note.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/note/delete_note.js
rename to src/public/app/widgets/bulk_actions/note/delete_note.ts
diff --git a/src/public/app/widgets/bulk_actions/note/delete_revisions.js b/src/public/app/widgets/bulk_actions/note/delete_revisions.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/note/delete_revisions.js
rename to src/public/app/widgets/bulk_actions/note/delete_revisions.ts
diff --git a/src/public/app/widgets/bulk_actions/note/move_note.js b/src/public/app/widgets/bulk_actions/note/move_note.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/note/move_note.js
rename to src/public/app/widgets/bulk_actions/note/move_note.ts
diff --git a/src/public/app/widgets/bulk_actions/note/rename_note.js b/src/public/app/widgets/bulk_actions/note/rename_note.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/note/rename_note.js
rename to src/public/app/widgets/bulk_actions/note/rename_note.ts

From 6f0d6a968d817a4b8e94066038d7ae0f43f7d69e Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 15:05:41 +0200
Subject: [PATCH 34/66] chore(client/ts): port bulk_actions/label

---
 src/public/app/widgets/bulk_actions/abstract_bulk_action.ts   | 4 ++++
 .../widgets/bulk_actions/label/{add_label.js => add_label.ts} | 0
 .../bulk_actions/label/{delete_label.js => delete_label.ts}   | 0
 .../bulk_actions/label/{rename_label.js => rename_label.ts}   | 0
 .../label/{update_label_value.js => update_label_value.ts}    | 0
 5 files changed, 4 insertions(+)
 rename src/public/app/widgets/bulk_actions/label/{add_label.js => add_label.ts} (100%)
 rename src/public/app/widgets/bulk_actions/label/{delete_label.js => delete_label.ts} (100%)
 rename src/public/app/widgets/bulk_actions/label/{rename_label.js => rename_label.ts} (100%)
 rename src/public/app/widgets/bulk_actions/label/{update_label_value.js => update_label_value.ts} (100%)

diff --git a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts
index dcde6a74d..d1cf333b5 100644
--- a/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts
+++ b/src/public/app/widgets/bulk_actions/abstract_bulk_action.ts
@@ -12,6 +12,10 @@ interface ActionDefinition {
     oldRelationName?: string;
     newRelationName?: string;
     newTitle?: string;
+    labelName?: string;
+    labelValue?: string;
+    oldLabelName?: string;
+    newLabelName?: string;
 }
 
 export default abstract class AbstractBulkAction {
diff --git a/src/public/app/widgets/bulk_actions/label/add_label.js b/src/public/app/widgets/bulk_actions/label/add_label.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/label/add_label.js
rename to src/public/app/widgets/bulk_actions/label/add_label.ts
diff --git a/src/public/app/widgets/bulk_actions/label/delete_label.js b/src/public/app/widgets/bulk_actions/label/delete_label.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/label/delete_label.js
rename to src/public/app/widgets/bulk_actions/label/delete_label.ts
diff --git a/src/public/app/widgets/bulk_actions/label/rename_label.js b/src/public/app/widgets/bulk_actions/label/rename_label.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/label/rename_label.js
rename to src/public/app/widgets/bulk_actions/label/rename_label.ts
diff --git a/src/public/app/widgets/bulk_actions/label/update_label_value.js b/src/public/app/widgets/bulk_actions/label/update_label_value.ts
similarity index 100%
rename from src/public/app/widgets/bulk_actions/label/update_label_value.js
rename to src/public/app/widgets/bulk_actions/label/update_label_value.ts

From 00870ba807cd83419daf5afa377443da23ac3c2f Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 15:09:52 +0200
Subject: [PATCH 35/66] chore(client/ts): port widgets/basic_widget

---
 src/public/app/components/component.ts        |  1 +
 .../{basic_widget.js => basic_widget.ts}      | 48 +++++++++++--------
 2 files changed, 28 insertions(+), 21 deletions(-)
 rename src/public/app/widgets/{basic_widget.js => basic_widget.ts} (81%)

diff --git a/src/public/app/components/component.ts b/src/public/app/components/component.ts
index 0156bdfc2..f8205e0b2 100644
--- a/src/public/app/components/component.ts
+++ b/src/public/app/components/component.ts
@@ -16,6 +16,7 @@ export default class Component {
     children: Component[];
     initialized: Promise | null;
     parent?: Component;
+    position!: number;
 
     constructor() {
         this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`;
diff --git a/src/public/app/widgets/basic_widget.js b/src/public/app/widgets/basic_widget.ts
similarity index 81%
rename from src/public/app/widgets/basic_widget.js
rename to src/public/app/widgets/basic_widget.ts
index 54b0b92a7..b1e91ec07 100644
--- a/src/public/app/widgets/basic_widget.js
+++ b/src/public/app/widgets/basic_widget.ts
@@ -9,6 +9,13 @@ import toastService from "../services/toast.js";
  * For information on using widgets, see the tutorial {@tutorial widget_basics}.
  */
 class BasicWidget extends Component {
+    private attrs: Record;
+    private classes: string[];
+    private childPositionCounter: number;
+    private cssEl?: string;
+    private $widget!: JQuery;
+    _noteId!: string;
+
     constructor() {
         super();
 
@@ -21,7 +28,7 @@ class BasicWidget extends Component {
         this.childPositionCounter = 10;
     }
 
-    child(...components) {
+    child(...components: Component[]) {
         if (!components) {
             return this;
         }
@@ -43,11 +50,11 @@ class BasicWidget extends Component {
     /**
      * Conditionally adds the given components as children to this component.
      * 
-     * @param {boolean} condition whether to add the components.
-     * @param  {...any} components the components to be added as children to this component provided the condition is truthy. 
+     * @param condition whether to add the components.
+     * @param components the components to be added as children to this component provided the condition is truthy. 
      * @returns self for chaining.
      */
-    optChild(condition, ...components) {
+    optChild(condition: boolean, ...components: Component[]) {
         if (condition) {
             return this.child(...components);
         } else {
@@ -55,12 +62,12 @@ class BasicWidget extends Component {
         }
     }
 
-    id(id) {
+    id(id: string) {
         this.attrs.id = id;
         return this;
     }
 
-    class(className) {
+    class(className: string) {
         this.classes.push(className);
         return this;
     }
@@ -68,11 +75,11 @@ class BasicWidget extends Component {
     /**
      * Sets the CSS attribute of the given name to the given value.
      * 
-     * @param {string} name the name of the CSS attribute to set (e.g. `padding-left`).
-     * @param {string} value the value of the CSS attribute to set (e.g. `12px`).
+     * @param name the name of the CSS attribute to set (e.g. `padding-left`).
+     * @param value the value of the CSS attribute to set (e.g. `12px`).
      * @returns self for chaining.
      */
-    css(name, value) {
+    css(name: string, value: string) {
         this.attrs.style += `${name}: ${value};`;
         return this;
     }
@@ -80,12 +87,12 @@ class BasicWidget extends Component {
     /**
      * Sets the CSS attribute of the given name to the given value, but only if the condition provided is truthy.
      * 
-     * @param {boolean} condition `true` in order to apply the CSS, `false` to ignore it.
-     * @param {string} name the name of the CSS attribute to set (e.g. `padding-left`).
-     * @param {string} value the value of the CSS attribute to set (e.g. `12px`).
+     * @param condition `true` in order to apply the CSS, `false` to ignore it.
+     * @param name the name of the CSS attribute to set (e.g. `padding-left`).
+     * @param value the value of the CSS attribute to set (e.g. `12px`).
      * @returns self for chaining.
      */
-    optCss(condition, name, value) {
+    optCss(condition: boolean, name: string, value: string) {
         if (condition) {
             return this.css(name, value);
         }
@@ -112,10 +119,9 @@ class BasicWidget extends Component {
 
     /**
      * Accepts a string of CSS to add with the widget.
-     * @param {string} block
-     * @returns {this} for chaining
+     * @returns for chaining
      */
-    cssBlock(block) {
+    cssBlock(block: string) {
         this.cssEl = block;
         return this;
     }
@@ -123,7 +129,7 @@ class BasicWidget extends Component {
     render() {
         try {
             this.doRender();
-        } catch (e) {                        
+        } catch (e: any) {                        
             this.logRenderingError(e);
         }
 
@@ -163,7 +169,7 @@ class BasicWidget extends Component {
         return this.$widget;
     }
 
-    logRenderingError(e) {
+    logRenderingError(e: Error) {
         console.log("Got issue in widget ", this);
         console.error(e);
 
@@ -175,7 +181,7 @@ class BasicWidget extends Component {
                     icon: "alert",
                     message: t("toast.widget-error.message-custom", {
                         id: noteId,
-                        title: note.title,
+                        title: note?.title,
                         message: e.message
                     })
                 });
@@ -208,7 +214,7 @@ class BasicWidget extends Component {
      */
     doRender() {}
 
-    toggleInt(show) {
+    toggleInt(show: boolean) {
         this.$widget.toggleClass('hidden-int', !show);
     }
 
@@ -216,7 +222,7 @@ class BasicWidget extends Component {
         return this.$widget.hasClass('hidden-int');
     }
 
-    toggleExt(show) {
+    toggleExt(show: boolean) {
         this.$widget.toggleClass('hidden-ext', !show);
     }
 

From cc8f9277181f434e02aa28d8b54f7194f89744c8 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 15:30:11 +0200
Subject: [PATCH 36/66] chore(client/ts): port widgets/dialogs/delete_notes

---
 src/public/app/services/tree.ts               |  5 +++
 src/public/app/widgets/basic_widget.ts        |  2 +-
 .../{delete_notes.js => delete_notes.ts}      | 43 +++++++++++++++++--
 3 files changed, 46 insertions(+), 4 deletions(-)
 rename src/public/app/widgets/dialogs/{delete_notes.js => delete_notes.ts} (82%)

diff --git a/src/public/app/services/tree.ts b/src/public/app/services/tree.ts
index 3a5cc9952..f5f7bd4d8 100644
--- a/src/public/app/services/tree.ts
+++ b/src/public/app/services/tree.ts
@@ -6,9 +6,14 @@ import appContext from "../components/app_context.js";
 
 export interface Node {
     getParent(): Node;
+    getChildren(): Node[];
+    folder: boolean;
+    renderTitle(): void,
     data: {
         noteId?: string;
         isProtected?: boolean;
+        branchId: string;
+        noteType: string;
     }
 }
 
diff --git a/src/public/app/widgets/basic_widget.ts b/src/public/app/widgets/basic_widget.ts
index b1e91ec07..6d4f587f5 100644
--- a/src/public/app/widgets/basic_widget.ts
+++ b/src/public/app/widgets/basic_widget.ts
@@ -13,7 +13,7 @@ class BasicWidget extends Component {
     private classes: string[];
     private childPositionCounter: number;
     private cssEl?: string;
-    private $widget!: JQuery;
+    protected $widget!: JQuery;
     _noteId!: string;
 
     constructor() {
diff --git a/src/public/app/widgets/dialogs/delete_notes.js b/src/public/app/widgets/dialogs/delete_notes.ts
similarity index 82%
rename from src/public/app/widgets/dialogs/delete_notes.js
rename to src/public/app/widgets/dialogs/delete_notes.ts
index acfab51c4..197df968b 100644
--- a/src/public/app/widgets/dialogs/delete_notes.js
+++ b/src/public/app/widgets/dialogs/delete_notes.ts
@@ -4,6 +4,25 @@ import linkService from "../../services/link.js";
 import utils from "../../services/utils.js";
 import BasicWidget from "../basic_widget.js";
 import { t } from "../../services/i18n.js";
+import FAttribute, { FAttributeRow } from "../../entities/fattribute.js";
+
+// TODO: Use common with server.
+interface Response {
+    noteIdsToBeDeleted: string[];
+    brokenRelations: FAttributeRow[];
+}
+
+interface ResolveOptions {
+    proceed: boolean;
+    deleteAllClones?: boolean;    
+    eraseNotes?: boolean;
+}
+
+interface ShowDeleteNotesDialogOpts {
+    branchIdsToDelete: string[];
+    callback: (opts: ResolveOptions) => void;
+    forceDeleteAllClones: boolean;
+}
 
 const TPL = `
 `;
 
 export default class DeleteNotesDialog extends BasicWidget {
+
+    private branchIds: string[] | null;
+    private resolve!: (options: ResolveOptions) => void;
+
+    private $content!: JQuery;
+    private $okButton!: JQuery;
+    private $cancelButton!: JQuery;
+    private $deleteNotesList!: JQuery;
+    private $brokenRelationsList!: JQuery;
+    private $deletedNotesCount!: JQuery;
+    private $noNoteToDeleteWrapper!: JQuery;
+    private $deleteNotesListWrapper!: JQuery;
+    private $brokenRelationsListWrapper!: JQuery;
+    private $brokenRelationsCount!: JQuery;
+    private $deleteAllClones!: JQuery;
+    private $eraseNotes!: JQuery;
+
+    private forceDeleteAllClones?: boolean;
+
     constructor() {
         super();
 
         this.branchIds = null;
-        this.resolve = null;
     }
 
     doRender() {
@@ -98,7 +135,7 @@ export default class DeleteNotesDialog extends BasicWidget {
     }
 
     async renderDeletePreview() {
-        const response = await server.post('delete-notes-preview', {
+        const response = await server.post('delete-notes-preview', {
             branchIdsToDelete: this.branchIds,
             deleteAllClones: this.forceDeleteAllClones || this.isDeleteAllClonesChecked()
         });
@@ -135,7 +172,7 @@ export default class DeleteNotesDialog extends BasicWidget {
         }
     }
 
-    async showDeleteNotesDialogEvent({branchIdsToDelete, callback, forceDeleteAllClones}) {
+    async showDeleteNotesDialogEvent({branchIdsToDelete, callback, forceDeleteAllClones}: ShowDeleteNotesDialogOpts) {
         this.branchIds = branchIdsToDelete;
         this.forceDeleteAllClones = forceDeleteAllClones;
 

From efb17c9010d63b0d21f85e65dde16fcd537f0d56 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 15:34:07 +0200
Subject: [PATCH 37/66] chore(client/ts): port services/branches

---
 src/public/app/components/app_context.ts      |  6 ++
 .../app/services/{branches.js => branches.ts} | 72 ++++++++++++-------
 .../app/widgets/dialogs/delete_notes.ts       |  2 +-
 3 files changed, 53 insertions(+), 27 deletions(-)
 rename src/public/app/services/{branches.js => branches.ts} (74%)

diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts
index debc0a132..ebda69402 100644
--- a/src/public/app/components/app_context.ts
+++ b/src/public/app/components/app_context.ts
@@ -15,6 +15,7 @@ import toast from "../services/toast.js";
 import ShortcutComponent from "./shortcut_component.js";
 import { t, initLocale } from "../services/i18n.js";
 import NoteDetailWidget from "../widgets/note_detail.js";
+import { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
 
 interface Layout {
     getRootWidget: (appContext: AppContext) => RootWidget;
@@ -43,6 +44,11 @@ export type TriggerData = {
 } | {
     // For "searchNotes"
     searchString: string | undefined;
+} | {
+    // For "showDeleteNotesDialog"
+    branchIdsToDelete: string[];
+    callback: (value: ResolveOptions) => void;
+    forceDeleteAllClones: boolean;
 }
 
 class AppContext extends Component {
diff --git a/src/public/app/services/branches.js b/src/public/app/services/branches.ts
similarity index 74%
rename from src/public/app/services/branches.js
rename to src/public/app/services/branches.ts
index 803c59147..20a8ec552 100644
--- a/src/public/app/services/branches.js
+++ b/src/public/app/services/branches.ts
@@ -1,17 +1,28 @@
 import utils from './utils.js';
 import server from './server.js';
-import toastService from "./toast.js";
+import toastService, { ToastOptions } from "./toast.js";
 import froca from "./froca.js";
 import hoistedNoteService from "./hoisted_note.js";
 import ws from "./ws.js";
 import appContext from "../components/app_context.js";
 import { t } from './i18n.js';
+import { Node } from './tree.js';
+import { ResolveOptions } from '../widgets/dialogs/delete_notes.js';
 
-async function moveBeforeBranch(branchIdsToMove, beforeBranchId) {
+// TODO: Deduplicate type with server
+interface Response {
+    success: boolean;
+    message: string;
+}
+
+async function moveBeforeBranch(branchIdsToMove: string[], beforeBranchId: string) {
     branchIdsToMove = filterRootNote(branchIdsToMove);
     branchIdsToMove = filterSearchBranches(branchIdsToMove);
 
     const beforeBranch = froca.getBranch(beforeBranchId);
+    if (!beforeBranch) {
+        return;
+    }
 
     if (['root', '_lbRoot', '_lbAvailableLaunchers', '_lbVisibleLaunchers'].includes(beforeBranch.noteId)) {
         toastService.showError(t("branches.cannot-move-notes-here"));
@@ -19,7 +30,7 @@ async function moveBeforeBranch(branchIdsToMove, beforeBranchId) {
     }
 
     for (const branchIdToMove of branchIdsToMove) {
-        const resp = await server.put(`branches/${branchIdToMove}/move-before/${beforeBranchId}`);
+        const resp = await server.put(`branches/${branchIdToMove}/move-before/${beforeBranchId}`);
 
         if (!resp.success) {
             toastService.showError(resp.message);
@@ -28,11 +39,14 @@ async function moveBeforeBranch(branchIdsToMove, beforeBranchId) {
     }
 }
 
-async function moveAfterBranch(branchIdsToMove, afterBranchId) {
+async function moveAfterBranch(branchIdsToMove: string[], afterBranchId: string) {
     branchIdsToMove = filterRootNote(branchIdsToMove);
     branchIdsToMove = filterSearchBranches(branchIdsToMove);
 
-    const afterNote = froca.getBranch(afterBranchId).getNote();
+    const afterNote = await froca.getBranch(afterBranchId)?.getNote();
+    if (!afterNote) {
+        return;
+    }
 
     const forbiddenNoteIds = [
         'root',
@@ -50,7 +64,7 @@ async function moveAfterBranch(branchIdsToMove, afterBranchId) {
     branchIdsToMove.reverse(); // need to reverse to keep the note order
 
     for (const branchIdToMove of branchIdsToMove) {
-        const resp = await server.put(`branches/${branchIdToMove}/move-after/${afterBranchId}`);
+        const resp = await server.put(`branches/${branchIdToMove}/move-after/${afterBranchId}`);
 
         if (!resp.success) {
             toastService.showError(resp.message);
@@ -59,8 +73,11 @@ async function moveAfterBranch(branchIdsToMove, afterBranchId) {
     }
 }
 
-async function moveToParentNote(branchIdsToMove, newParentBranchId) {
+async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: string) {
     const newParentBranch = froca.getBranch(newParentBranchId);
+    if (!newParentBranch) {
+        return;
+    }
 
     if (newParentBranch.noteId === '_lbRoot') {
         toastService.showError(t("branches.cannot-move-notes-here"));
@@ -72,12 +89,13 @@ async function moveToParentNote(branchIdsToMove, newParentBranchId) {
     for (const branchIdToMove of branchIdsToMove) {
         const branchToMove = froca.getBranch(branchIdToMove);
 
-        if (branchToMove.noteId === hoistedNoteService.getHoistedNoteId()
-            || (await branchToMove.getParentNote()).type === 'search') {
+        if (!branchToMove
+            || branchToMove.noteId === hoistedNoteService.getHoistedNoteId()
+            || (await branchToMove.getParentNote())?.type === 'search') {
             continue;
         }
 
-        const resp = await server.put(`branches/${branchIdToMove}/move-to/${newParentBranchId}`);
+        const resp = await server.put(`branches/${branchIdToMove}/move-to/${newParentBranchId}`);
 
         if (!resp.success) {
             toastService.showError(resp.message);
@@ -86,7 +104,7 @@ async function moveToParentNote(branchIdsToMove, newParentBranchId) {
     }
 }
 
-async function deleteNotes(branchIdsToDelete, forceDeleteAllClones = false) {
+async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false) {
     branchIdsToDelete = filterRootNote(branchIdsToDelete);
 
     if (branchIdsToDelete.length === 0) {
@@ -100,7 +118,7 @@ async function deleteNotes(branchIdsToDelete, forceDeleteAllClones = false) {
         deleteAllClones = false;
     }
     else {
-        ({proceed, deleteAllClones, eraseNotes} = await new Promise(res =>
+        ({proceed, deleteAllClones, eraseNotes} = await new Promise(res =>
             appContext.triggerCommand('showDeleteNotesDialog', {branchIdsToDelete, callback: res, forceDeleteAllClones})));
     }
 
@@ -127,10 +145,9 @@ async function deleteNotes(branchIdsToDelete, forceDeleteAllClones = false) {
 
         const branch = froca.getBranch(branchIdToDelete);
 
-        if (deleteAllClones) {
+        if (deleteAllClones && branch) {
             await server.remove(`notes/${branch.noteId}${query}`);
-        }
-        else {
+        } else {
             await server.remove(`branches/${branchIdToDelete}${query}`);
         }
     }
@@ -152,7 +169,7 @@ async function activateParentNotePath() {
     }
 }
 
-async function moveNodeUpInHierarchy(node) {
+async function moveNodeUpInHierarchy(node: Node) {
     if (hoistedNoteService.isHoistedNode(node)
         || hoistedNoteService.isTopLevelNode(node)
         || node.getParent().data.noteType === 'search') {
@@ -162,7 +179,7 @@ async function moveNodeUpInHierarchy(node) {
     const targetBranchId = node.getParent().data.branchId;
     const branchIdToMove = node.data.branchId;
 
-    const resp = await server.put(`branches/${branchIdToMove}/move-after/${targetBranchId}`);
+    const resp = await server.put(`branches/${branchIdToMove}/move-after/${targetBranchId}`);
 
     if (!resp.success) {
         toastService.showError(resp.message);
@@ -175,22 +192,25 @@ async function moveNodeUpInHierarchy(node) {
     }
 }
 
-function filterSearchBranches(branchIds) {
+function filterSearchBranches(branchIds: string[]) {
     return branchIds.filter(branchId => !branchId.startsWith('virt-'));
 }
 
-function filterRootNote(branchIds) {
+function filterRootNote(branchIds: string[]) {
     const hoistedNoteId = hoistedNoteService.getHoistedNoteId();
 
     return branchIds.filter(branchId => {
         const branch = froca.getBranch(branchId);
+        if (!branch) {
+            return false;
+        }
 
         return branch.noteId !== 'root'
             && branch.noteId !== hoistedNoteId;
     });
 }
 
-function makeToast(id, message) {
+function makeToast(id: string, message: string): ToastOptions {
     return {
         id: id,
         title: t("branches.delete-status"),
@@ -235,8 +255,8 @@ ws.subscribeToMessages(async message => {
     }
 });
 
-async function cloneNoteToBranch(childNoteId, parentBranchId, prefix) {
-    const resp = await server.put(`notes/${childNoteId}/clone-to-branch/${parentBranchId}`, {
+async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, prefix: string) {
+    const resp = await server.put(`notes/${childNoteId}/clone-to-branch/${parentBranchId}`, {
         prefix: prefix
     });
 
@@ -245,8 +265,8 @@ async function cloneNoteToBranch(childNoteId, parentBranchId, prefix) {
     }
 }
 
-async function cloneNoteToParentNote(childNoteId, parentNoteId, prefix) {
-    const resp = await server.put(`notes/${childNoteId}/clone-to-note/${parentNoteId}`, {
+async function cloneNoteToParentNote(childNoteId: string, parentNoteId: string, prefix: string) {
+    const resp = await server.put(`notes/${childNoteId}/clone-to-note/${parentNoteId}`, {
         prefix: prefix
     });
 
@@ -256,8 +276,8 @@ async function cloneNoteToParentNote(childNoteId, parentNoteId, prefix) {
 }
 
 // beware that the first arg is noteId and the second is branchId!
-async function cloneNoteAfter(noteId, afterBranchId) {
-    const resp = await server.put(`notes/${noteId}/clone-after/${afterBranchId}`);
+async function cloneNoteAfter(noteId: string, afterBranchId: string) {
+    const resp = await server.put(`notes/${noteId}/clone-after/${afterBranchId}`);
 
     if (!resp.success) {
         toastService.showError(resp.message);
diff --git a/src/public/app/widgets/dialogs/delete_notes.ts b/src/public/app/widgets/dialogs/delete_notes.ts
index 197df968b..c4aa28a75 100644
--- a/src/public/app/widgets/dialogs/delete_notes.ts
+++ b/src/public/app/widgets/dialogs/delete_notes.ts
@@ -12,7 +12,7 @@ interface Response {
     brokenRelations: FAttributeRow[];
 }
 
-interface ResolveOptions {
+export interface ResolveOptions {
     proceed: boolean;
     deleteAllClones?: boolean;    
     eraseNotes?: boolean;

From 05e49f77e685babc184b8aa4755dd0903af5c2fc Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 15:34:15 +0200
Subject: [PATCH 38/66] chore(client/ts): remove unused type definition

---
 src/public/app/services/attribute_parser.d.ts | 7 -------
 1 file changed, 7 deletions(-)
 delete mode 100644 src/public/app/services/attribute_parser.d.ts

diff --git a/src/public/app/services/attribute_parser.d.ts b/src/public/app/services/attribute_parser.d.ts
deleted file mode 100644
index 58fe9b916..000000000
--- a/src/public/app/services/attribute_parser.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-declare module 'attribute_parser';
-
-
-export function lex(str: string): any[]
-export function parse(tokens: any[], str?: string, allowEmptyRelations?: boolean): any[]
-export function lexAndParse(str: string, allowEmptyRelations?: boolean): any[]
-

From c956d4358ca1933e23a29c15629d75de390cb895 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 16:36:16 +0200
Subject: [PATCH 39/66] chore(client/ts): port services/bulk_action

---
 src/public/app/services/{bulk_action.js => bulk_action.ts} | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)
 rename src/public/app/services/{bulk_action.js => bulk_action.ts} (95%)

diff --git a/src/public/app/services/bulk_action.js b/src/public/app/services/bulk_action.ts
similarity index 95%
rename from src/public/app/services/bulk_action.js
rename to src/public/app/services/bulk_action.ts
index 313f36a8f..5a039ea4f 100644
--- a/src/public/app/services/bulk_action.js
+++ b/src/public/app/services/bulk_action.ts
@@ -14,6 +14,7 @@ import AddLabelBulkAction from "../widgets/bulk_actions/label/add_label.js";
 import AddRelationBulkAction from "../widgets/bulk_actions/relation/add_relation.js";
 import RenameNoteBulkAction from "../widgets/bulk_actions/note/rename_note.js";
 import { t } from "./i18n.js";
+import FNote from "../entities/fnote.js";
 
 const ACTION_GROUPS = [
     {
@@ -50,7 +51,7 @@ const ACTION_CLASSES = [
     ExecuteScriptBulkAction
 ];
 
-async function addAction(noteId, actionName) {
+async function addAction(noteId: string, actionName: string) {
     await server.post(`notes/${noteId}/attributes`, {
         type: 'label',
         name: 'action',
@@ -62,7 +63,7 @@ async function addAction(noteId, actionName) {
     await ws.waitForMaxKnownEntityChangeId();
 }
 
-function parseActions(note) {
+function parseActions(note: FNote) {
     const actionLabels = note.getLabels('action');
 
     return actionLabels.map(actionAttr => {
@@ -70,7 +71,7 @@ function parseActions(note) {
 
         try {
             actionDef = JSON.parse(actionAttr.value);
-        } catch (e) {
+        } catch (e: any) {
             logError(`Parsing of attribute: '${actionAttr.value}' failed with error: ${e.message}`);
             return null;
         }

From c0e9684f73f976a395e87593a2ab825c1b5e5203 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 16:43:50 +0200
Subject: [PATCH 40/66] chore(client/ts): port services/bundle

---
 .../app/services/{bundle.js => bundle.ts}     | 40 +++++++++++++------
 .../app/services/frontend_script_api.ts       |  2 +-
 src/public/app/services/script_context.ts     |  4 +-
 src/public/app/types.d.ts                     |  2 +-
 4 files changed, 31 insertions(+), 17 deletions(-)
 rename src/public/app/services/{bundle.js => bundle.ts} (72%)

diff --git a/src/public/app/services/bundle.js b/src/public/app/services/bundle.ts
similarity index 72%
rename from src/public/app/services/bundle.js
rename to src/public/app/services/bundle.ts
index e0a81eee4..e3ba2c21a 100644
--- a/src/public/app/services/bundle.js
+++ b/src/public/app/services/bundle.ts
@@ -4,9 +4,21 @@ import toastService from "./toast.js";
 import froca from "./froca.js";
 import utils from "./utils.js";
 import { t } from "./i18n.js";
+import { Entity } from "./frontend_script_api.js";
 
-async function getAndExecuteBundle(noteId, originEntity = null, script = null, params = null) {
-    const bundle = await server.post(`script/bundle/${noteId}`, {
+// TODO: Deduplicate with server.
+interface Bundle {
+    script: string;
+    noteId: string;
+    allNoteIds: string[];
+}
+
+interface Widget {
+    parentWidget?: string;
+}
+
+async function getAndExecuteBundle(noteId: string, originEntity = null, script = null, params = null) {
+    const bundle = await server.post(`script/bundle/${noteId}`, {
         script,
         params
     });
@@ -14,24 +26,23 @@ async function getAndExecuteBundle(noteId, originEntity = null, script = null, p
     return await executeBundle(bundle, originEntity);
 }
 
-async function executeBundle(bundle, originEntity, $container) {
+async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery) {
     const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
 
     try {
         return await (function () {
             return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`);
         }.call(apiContext));
-    }
-    catch (e) {
+    } catch (e: any) {
         const note = await froca.getNote(bundle.noteId);
 
-        toastService.showAndLogError(`Execution of JS note "${note.title}" with ID ${bundle.noteId} failed with error: ${e.message}`);
+        toastService.showAndLogError(`Execution of JS note "${note?.title}" with ID ${bundle.noteId} failed with error: ${e?.message}`);
     }
 }
 
 async function executeStartupBundles() {
     const isMobile = utils.isMobile();
-    const scriptBundles = await server.get("script/startup" + (isMobile ? "?mobile=true" : ""));
+    const scriptBundles = await server.get("script/startup" + (isMobile ? "?mobile=true" : ""));
 
     for (const bundle of scriptBundles) {
         await executeBundle(bundle);
@@ -39,11 +50,14 @@ async function executeStartupBundles() {
 }
 
 class WidgetsByParent {
+
+    private byParent: Record;
+
     constructor() {
         this.byParent = {};
     }
 
-    add(widget) {
+    add(widget: Widget) {
         if (!widget.parentWidget) {
             console.log(`Custom widget does not have mandatory 'parentWidget' property defined`);
             return;
@@ -53,7 +67,7 @@ class WidgetsByParent {
         this.byParent[widget.parentWidget].push(widget);
     }
 
-    get(parentName) {
+    get(parentName: string) {
         if (!this.byParent[parentName]) {
             return [];
         }
@@ -62,12 +76,12 @@ class WidgetsByParent {
             // previously, custom widgets were provided as a single instance, but that has the disadvantage
             // for splits where we actually need multiple instaces and thus having a class to instantiate is better
             // https://github.com/zadam/trilium/issues/4274
-            .map(w => w.prototype ? new w() : w);
+            .map((w: any) => w.prototype ? new w() : w);
     }
 }
 
 async function getWidgetBundlesByParent() {
-    const scriptBundles = await server.get("script/widgets");
+    const scriptBundles = await server.get("script/widgets");
 
     const widgetsByParent = new WidgetsByParent();
 
@@ -80,7 +94,7 @@ async function getWidgetBundlesByParent() {
                 widget._noteId = bundle.noteId;
                 widgetsByParent.add(widget);
             }
-        } catch (e) {
+        } catch (e: any) {
             const noteId = bundle.noteId;
             const note = await froca.getNote(noteId);
             toastService.showPersistent({
@@ -88,7 +102,7 @@ async function getWidgetBundlesByParent() {
                 icon: "alert",
                 message: t("toast.bundle-error.message", {
                     id: noteId,
-                    title: note.title,
+                    title: note?.title,
                     message: e.message
                 })
             });
diff --git a/src/public/app/services/frontend_script_api.ts b/src/public/app/services/frontend_script_api.ts
index 905226d2d..6a3c61c49 100644
--- a/src/public/app/services/frontend_script_api.ts
+++ b/src/public/app/services/frontend_script_api.ts
@@ -53,7 +53,7 @@ interface ExecResult {
     error?: string;
 }
 
-interface Entity {
+export interface Entity {
     noteId: string;
 }
 
diff --git a/src/public/app/services/script_context.ts b/src/public/app/services/script_context.ts
index 821f9aec0..7d76c12a7 100644
--- a/src/public/app/services/script_context.ts
+++ b/src/public/app/services/script_context.ts
@@ -1,8 +1,8 @@
-import FrontendScriptApi from './frontend_script_api.js';
+import FrontendScriptApi, { Entity } from './frontend_script_api.js';
 import utils from './utils.js';
 import froca from './froca.js';
 
-async function ScriptContext(startNoteId: string, allNoteIds: string[], originEntity = null, $container: JQuery | null = null) {
+async function ScriptContext(startNoteId: string, allNoteIds: string[], originEntity: Entity | null = null, $container: JQuery | null = null) {
     const modules: Record = {};
 
     await froca.initializedPromise;
diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts
index 13c506091..14cc2c19a 100644
--- a/src/public/app/types.d.ts
+++ b/src/public/app/types.d.ts
@@ -82,7 +82,7 @@ declare global {
         setNote(noteId: string);
     }
 
-    var logError: (message: string) => void;
+    var logError: (message: string, e?: Error) => void;
     var logInfo: (message: string) => void;    
     var glob: CustomGlobals;
     var require: RequireMethod;

From 911323c0990cbfd2127eeae49f479ec1180a5599 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 16:48:14 +0200
Subject: [PATCH 41/66] chore(client/ts): port services/clipboard

---
 src/public/app/services/branches.ts           |  2 +-
 .../services/{clipboard.js => clipboard.ts}   | 26 ++++++++++++++-----
 2 files changed, 21 insertions(+), 7 deletions(-)
 rename src/public/app/services/{clipboard.js => clipboard.ts} (83%)

diff --git a/src/public/app/services/branches.ts b/src/public/app/services/branches.ts
index 20a8ec552..6eb5c07ff 100644
--- a/src/public/app/services/branches.ts
+++ b/src/public/app/services/branches.ts
@@ -255,7 +255,7 @@ ws.subscribeToMessages(async message => {
     }
 });
 
-async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, prefix: string) {
+async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, prefix?: string) {
     const resp = await server.put(`notes/${childNoteId}/clone-to-branch/${parentBranchId}`, {
         prefix: prefix
     });
diff --git a/src/public/app/services/clipboard.js b/src/public/app/services/clipboard.ts
similarity index 83%
rename from src/public/app/services/clipboard.js
rename to src/public/app/services/clipboard.ts
index dda27f598..d1cb708e3 100644
--- a/src/public/app/services/clipboard.js
+++ b/src/public/app/services/clipboard.ts
@@ -5,10 +5,10 @@ import linkService from "./link.js";
 import utils from "./utils.js";
 import { t } from "./i18n.js";
 
-let clipboardBranchIds = [];
-let clipboardMode = null;
+let clipboardBranchIds: string[] = [];
+let clipboardMode: string | null = null;
 
-async function pasteAfter(afterBranchId) {
+async function pasteAfter(afterBranchId: string) {
     if (isClipboardEmpty()) {
         return;
     }
@@ -23,7 +23,14 @@ async function pasteAfter(afterBranchId) {
         const clipboardBranches = clipboardBranchIds.map(branchId => froca.getBranch(branchId));
 
         for (const clipboardBranch of clipboardBranches) {
+            if (!clipboardBranch) {
+                continue;
+            }
+
             const clipboardNote = await clipboardBranch.getNote();
+            if (!clipboardNote) {
+                continue;
+            }
 
             await branchService.cloneNoteAfter(clipboardNote.noteId, afterBranchId);
         }
@@ -35,7 +42,7 @@ async function pasteAfter(afterBranchId) {
     }
 }
 
-async function pasteInto(parentBranchId) {
+async function pasteInto(parentBranchId: string) {
     if (isClipboardEmpty()) {
         return;
     }
@@ -50,7 +57,14 @@ async function pasteInto(parentBranchId) {
         const clipboardBranches = clipboardBranchIds.map(branchId => froca.getBranch(branchId));
 
         for (const clipboardBranch of clipboardBranches) {
+            if (!clipboardBranch) {
+                continue;
+            }
+
             const clipboardNote = await clipboardBranch.getNote();
+            if (!clipboardNote) {
+                continue;
+            }
 
             await branchService.cloneNoteToBranch(clipboardNote.noteId, parentBranchId);
         }
@@ -62,7 +76,7 @@ async function pasteInto(parentBranchId) {
     }
 }
 
-async function copy(branchIds) {
+async function copy(branchIds: string[]) {
     clipboardBranchIds = branchIds;
     clipboardMode = 'copy';
 
@@ -82,7 +96,7 @@ async function copy(branchIds) {
     toastService.showMessage(t("clipboard.copied"));
 }
 
-function cut(branchIds) {
+function cut(branchIds: string[]) {
     clipboardBranchIds = branchIds;
 
     if (clipboardBranchIds.length > 0) {

From 7fc4443206026b03f70b618182aeab924f9282b0 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 17:00:36 +0200
Subject: [PATCH 42/66] chore(client/ts): port services/debounce

---
 .../app/services/{debounce.js => debounce.ts} | 24 +++++++++++--------
 1 file changed, 14 insertions(+), 10 deletions(-)
 rename src/public/app/services/{debounce.js => debounce.ts} (70%)

diff --git a/src/public/app/services/debounce.js b/src/public/app/services/debounce.ts
similarity index 70%
rename from src/public/app/services/debounce.js
rename to src/public/app/services/debounce.ts
index 4e5429a32..4f9972a7b 100644
--- a/src/public/app/services/debounce.js
+++ b/src/public/app/services/debounce.ts
@@ -7,13 +7,17 @@
  *
  * @source underscore.js
  * @see http://unscriptable.com/2009/03/20/debouncing-javascript-methods/
- * @param {Function} func to wrap
- * @param {Number} waitMs in ms (`100`)
- * @param {Boolean} [immediate=false] whether to execute at the beginning (`false`)
+ * @param func to wrap
+ * @param waitMs in ms (`100`)
+ * @param whether to execute at the beginning (`false`)
  * @api public
  */
-function debounce(func, waitMs, immediate = false) {
-    let timeout, args, context, timestamp, result;
+function debounce(func: (...args: unknown[]) => T, waitMs: number, immediate: boolean = false) {
+    let timeout: any; // TODO: fix once we split client and server.
+    let args: unknown[] | null;
+    let context: unknown;
+    let timestamp: number;
+    let result: T;
     if (null == waitMs) waitMs = 100;
 
     function later() {
@@ -24,20 +28,20 @@ function debounce(func, waitMs, immediate = false) {
         } else {
             timeout = null;
             if (!immediate) {
-                result = func.apply(context, args);
+                result = func.apply(context, args || []);
                 context = args = null;
             }
         }
     }
 
-    const debounced = function () {
+    const debounced = function (this: any) {
         context = this;
-        args = arguments;
+        args = arguments as unknown as unknown[];
         timestamp = Date.now();
         const callNow = immediate && !timeout;
         if (!timeout) timeout = setTimeout(later, waitMs);
         if (callNow) {
-            result = func.apply(context, args);
+            result = func.apply(context, args || []);
             context = args = null;
         }
 
@@ -53,7 +57,7 @@ function debounce(func, waitMs, immediate = false) {
 
     debounced.flush = function() {
         if (timeout) {
-            result = func.apply(context, args);
+            result = func.apply(context, args || []);
             context = args = null;
 
             clearTimeout(timeout);

From e54e8fdef88f56dcf7fc32d3d3b84c7051522bb5 Mon Sep 17 00:00:00 2001
From: Elian Doran 
Date: Sat, 21 Dec 2024 17:12:16 +0200
Subject: [PATCH 43/66] chore(client/ts): port widgets/dialogs/prompt

---
 src/public/app/components/app_context.ts      |  3 +-
 .../widgets/dialogs/{prompt.js => prompt.ts}  | 39 +++++++++++++++++--
 2 files changed, 37 insertions(+), 5 deletions(-)
 rename src/public/app/widgets/dialogs/{prompt.js => prompt.ts} (71%)

diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts
index ebda69402..5ccb72ea4 100644
--- a/src/public/app/components/app_context.ts
+++ b/src/public/app/components/app_context.ts
@@ -16,6 +16,7 @@ import ShortcutComponent from "./shortcut_component.js";
 import { t, initLocale } from "../services/i18n.js";
 import NoteDetailWidget from "../widgets/note_detail.js";
 import { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
+import { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
 
 interface Layout {
     getRootWidget: (appContext: AppContext) => RootWidget;
@@ -49,7 +50,7 @@ export type TriggerData = {
     branchIdsToDelete: string[];
     callback: (value: ResolveOptions) => void;
     forceDeleteAllClones: boolean;
-}
+} | PromptDialogOptions;    // For "showPromptDialog"
 
 class AppContext extends Component {
 
diff --git a/src/public/app/widgets/dialogs/prompt.js b/src/public/app/widgets/dialogs/prompt.ts
similarity index 71%
rename from src/public/app/widgets/dialogs/prompt.js
rename to src/public/app/widgets/dialogs/prompt.ts
index c8a1fa016..8a470860c 100644
--- a/src/public/app/widgets/dialogs/prompt.js
+++ b/src/public/app/widgets/dialogs/prompt.ts
@@ -20,7 +20,34 @@ const TPL = `
     
`; +interface ShownCallbackData { + $dialog: JQuery; + $question: JQuery | null; + $answer: JQuery | null; + $form: JQuery; +} + +export interface PromptDialogOptions { + title?: string; + message?: string; + defaultValue?: string; + shown: PromptShownDialogCallback; + callback: () => void; +} + +export type PromptShownDialogCallback = ((callback: ShownCallbackData) => void) | null; + export default class PromptDialog extends BasicWidget { + + private resolve: ((val: string | null) => void) | null; + private shownCb: PromptShownDialogCallback; + + private modal!: bootstrap.Modal; + private $dialogBody!: JQuery; + private $question!: JQuery | null; + private $answer!: JQuery | null; + private $form!: JQuery; + constructor() { super(); @@ -30,6 +57,8 @@ export default class PromptDialog extends BasicWidget { doRender() { this.$widget = $(TPL); + // TODO: Fix once we use proper ES imports. + //@ts-ignore this.modal = bootstrap.Modal.getOrCreateInstance(this.$widget); this.$dialogBody = this.$widget.find(".modal-body"); this.$form = this.$widget.find(".prompt-dialog-form"); @@ -46,7 +75,7 @@ export default class PromptDialog extends BasicWidget { }); } - this.$answer.trigger('focus').select(); + this.$answer?.trigger('focus').select(); }); this.$widget.on("hidden.bs.modal", () => { @@ -57,13 +86,15 @@ export default class PromptDialog extends BasicWidget { this.$form.on('submit', e => { e.preventDefault(); - this.resolve(this.$answer.val()); + if (this.resolve) { + this.resolve(this.$answer?.val() as string); + } this.modal.hide(); }); } - showPromptDialogEvent({ title, message, defaultValue, shown, callback }) { + showPromptDialogEvent({ title, message, defaultValue, shown, callback }: PromptDialogOptions) { this.shownCb = shown; this.resolve = callback; @@ -71,7 +102,7 @@ export default class PromptDialog extends BasicWidget { this.$question = $("
`; +export type ConfirmDialogCallback = (val: false | { + confirmed: boolean; + isDeleteNoteChecked: boolean +}) => void; + +interface ConfirmWithMessageOptions { + message: string | HTMLElement | JQuery; + callback: ConfirmDialogCallback; +} + +interface ConfirmWithTitleOptions { + title: string; + callback: ConfirmDialogCallback; +} + export default class ConfirmDialog extends BasicWidget { + + private resolve: ConfirmDialogCallback | null; + + private modal!: bootstrap.Modal; + private $originallyFocused!: JQuery | null; + private $confirmContent!: JQuery; + private $okButton!: JQuery; + private $cancelButton!: JQuery; + private $custom!: JQuery; + constructor() { super(); @@ -37,6 +62,8 @@ export default class ConfirmDialog extends BasicWidget { doRender() { this.$widget = $(TPL); + // TODO: Fix once we use proper ES imports. + //@ts-ignore this.modal = bootstrap.Modal.getOrCreateInstance(this.$widget); this.$confirmContent = this.$widget.find(".confirm-dialog-content"); this.$okButton = this.$widget.find(".confirm-dialog-ok-button"); @@ -60,7 +87,7 @@ export default class ConfirmDialog extends BasicWidget { this.$okButton.on('click', () => this.doResolve(true)); } - showConfirmDialogEvent({ message, callback }) { + showConfirmDialogEvent({ message, callback }: ConfirmWithMessageOptions) { this.$originallyFocused = $(':focus'); this.$custom.hide(); @@ -77,8 +104,8 @@ export default class ConfirmDialog extends BasicWidget { this.resolve = callback; } - - showConfirmDeleteNoteBoxWithNoteDialogEvent({ title, callback }) { + + showConfirmDeleteNoteBoxWithNoteDialogEvent({ title, callback }: ConfirmWithTitleOptions) { glob.activeDialog = this.$widget; this.$confirmContent.text(`${t('confirm.are_you_sure_remove_note', { title: title })}`); @@ -107,11 +134,13 @@ export default class ConfirmDialog extends BasicWidget { this.resolve = callback; } - doResolve(ret) { - this.resolve({ - confirmed: ret, - isDeleteNoteChecked: this.$widget.find(`.${DELETE_NOTE_BUTTON_CLASS}:checked`).length > 0 - }); + doResolve(ret: boolean) { + if (this.resolve) { + this.resolve({ + confirmed: ret, + isDeleteNoteChecked: this.$widget.find(`.${DELETE_NOTE_BUTTON_CLASS}:checked`).length > 0 + }); + } this.resolve = null; From 45a652828eb8fb3b03f36848a887b4250924adb7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 17:39:14 +0200 Subject: [PATCH 45/66] chore(client/ts): port widgets/dialogs/confirm --- src/public/app/components/app_context.ts | 6 ++++-- src/public/app/services/{dialog.js => dialog.ts} | 14 ++++++++------ src/public/app/widgets/dialogs/confirm.ts | 10 +++++++--- 3 files changed, 19 insertions(+), 11 deletions(-) rename src/public/app/services/{dialog.js => dialog.ts} (52%) diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 5ccb72ea4..36ebb2d17 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -17,6 +17,7 @@ import { t, initLocale } from "../services/i18n.js"; import NoteDetailWidget from "../widgets/note_detail.js"; import { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; import { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; +import { ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js"; interface Layout { getRootWidget: (appContext: AppContext) => RootWidget; @@ -34,7 +35,6 @@ export type TriggerData = { noteId?: string; noteIds?: string[]; messages?: unknown[]; - callback?: () => void; } | { ntxId: string; notePath: string; @@ -50,7 +50,9 @@ export type TriggerData = { branchIdsToDelete: string[]; callback: (value: ResolveOptions) => void; forceDeleteAllClones: boolean; -} | PromptDialogOptions; // For "showPromptDialog" +} + | PromptDialogOptions // For "showPromptDialog" + | ConfirmWithMessageOptions // For "showConfirmDialog" class AppContext extends Component { diff --git a/src/public/app/services/dialog.js b/src/public/app/services/dialog.ts similarity index 52% rename from src/public/app/services/dialog.js rename to src/public/app/services/dialog.ts index 325a65146..18db1df40 100644 --- a/src/public/app/services/dialog.js +++ b/src/public/app/services/dialog.ts @@ -1,24 +1,26 @@ import appContext from "../components/app_context.js"; +import { ConfirmDialogOptions, ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js"; +import { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; -async function info(message) { +async function info(message: string) { return new Promise(res => appContext.triggerCommand("showInfoDialog", {message, callback: res})); } -async function confirm(message) { +async function confirm(message: string) { return new Promise(res => - appContext.triggerCommand("showConfirmDialog", { + appContext.triggerCommand("showConfirmDialog", { message, - callback: x => res(x.confirmed) + callback: (x: false | ConfirmDialogOptions) => res(x && x.confirmed) })); } -async function confirmDeleteNoteBoxWithNote(title) { +async function confirmDeleteNoteBoxWithNote(title: string) { return new Promise(res => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", {title, callback: res})); } -async function prompt(props) { +async function prompt(props: PromptDialogOptions) { return new Promise(res => appContext.triggerCommand("showPromptDialog", {...props, callback: res})); } diff --git a/src/public/app/widgets/dialogs/confirm.ts b/src/public/app/widgets/dialogs/confirm.ts index 1e983b08a..5054eb70d 100644 --- a/src/public/app/widgets/dialogs/confirm.ts +++ b/src/public/app/widgets/dialogs/confirm.ts @@ -27,12 +27,16 @@ const TPL = ` `; -export type ConfirmDialogCallback = (val: false | { +type ConfirmDialogCallback = (val: false | ConfirmDialogOptions) => void; + +export interface ConfirmDialogOptions { confirmed: boolean; isDeleteNoteChecked: boolean -}) => void; +} -interface ConfirmWithMessageOptions { +// For "showConfirmDialog" + +export interface ConfirmWithMessageOptions { message: string | HTMLElement | JQuery; callback: ConfirmDialogCallback; } From 6e8fa6d7571be286c085d22a5c95be2ece639f6e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 17:42:48 +0200 Subject: [PATCH 46/66] chore(client/ts): port services/file_watcher --- src/public/app/components/app_context.ts | 6 ++++++ .../{file_watcher.js => file_watcher.ts} | 21 +++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) rename src/public/app/services/{file_watcher.js => file_watcher.ts} (66%) diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 36ebb2d17..5303acd08 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -50,6 +50,12 @@ export type TriggerData = { branchIdsToDelete: string[]; callback: (value: ResolveOptions) => void; forceDeleteAllClones: boolean; +} | { + // For "openedFileUpdated" + entityType: string; + entityId: string; + lastModifiedMs: number; + filePath: string; } | PromptDialogOptions // For "showPromptDialog" | ConfirmWithMessageOptions // For "showConfirmDialog" diff --git a/src/public/app/services/file_watcher.js b/src/public/app/services/file_watcher.ts similarity index 66% rename from src/public/app/services/file_watcher.js rename to src/public/app/services/file_watcher.ts index a0db524cc..0f9ec3bb5 100644 --- a/src/public/app/services/file_watcher.js +++ b/src/public/app/services/file_watcher.ts @@ -1,36 +1,45 @@ import ws from "./ws.js"; import appContext from "../components/app_context.js"; -const fileModificationStatus = { +// TODO: Deduplicate +interface Message { + type: string; + entityType: string; + entityId: string; + lastModifiedMs: number; + filePath: string; +} + +const fileModificationStatus: Record> = { notes: {}, attachments: {} }; -function checkType(type) { +function checkType(type: string) { if (type !== 'notes' && type !== 'attachments') { throw new Error(`Unrecognized type '${type}', should be 'notes' or 'attachments'`); } } -function getFileModificationStatus(entityType, entityId) { +function getFileModificationStatus(entityType: string, entityId: string) { checkType(entityType); return fileModificationStatus[entityType][entityId]; } -function fileModificationUploaded(entityType, entityId) { +function fileModificationUploaded(entityType: string, entityId: string) { checkType(entityType); delete fileModificationStatus[entityType][entityId]; } -function ignoreModification(entityType, entityId) { +function ignoreModification(entityType: string, entityId: string) { checkType(entityType); delete fileModificationStatus[entityType][entityId]; } -ws.subscribeToMessages(async message => { +ws.subscribeToMessages(async (message: Message) => { if (message.type !== 'openedFileUpdated') { return; } From 14dd3a00214bf4a3c583f51737087358a121bbe5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 17:47:09 +0200 Subject: [PATCH 47/66] chore(client/ts): port services/glob --- src/public/app/services/{glob.js => glob.ts} | 2 +- src/public/app/services/utils.ts | 2 +- src/public/app/types.d.ts | 12 ++++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) rename src/public/app/services/{glob.js => glob.ts} (98%) diff --git a/src/public/app/services/glob.js b/src/public/app/services/glob.ts similarity index 98% rename from src/public/app/services/glob.js rename to src/public/app/services/glob.ts index f523a677e..e5f9e744d 100644 --- a/src/public/app/services/glob.js +++ b/src/public/app/services/glob.ts @@ -27,7 +27,7 @@ function setupGlobs() { window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline"); window.onerror = function (msg, url, lineNo, columnNo, error) { - const string = msg.toLowerCase(); + const string = String(msg).toLowerCase(); let message = "Uncaught error: "; diff --git a/src/public/app/services/utils.ts b/src/public/app/services/utils.ts index 6e2937beb..47bc2c1e9 100644 --- a/src/public/app/services/utils.ts +++ b/src/public/app/services/utils.ts @@ -350,7 +350,7 @@ function openHelp($button: JQuery) { } } -function initHelpButtons($el: JQuery) { +function initHelpButtons($el: JQuery | JQuery) { // 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 => { diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 14cc2c19a..6e2f57ccf 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -3,6 +3,9 @@ import type { BackendModule, i18n } from "i18next"; import type { Froca } from "./services/froca-interface"; import type { HttpBackendOptions } from "i18next-http-backend"; import { Suggestion } from "./services/note_autocomplete.ts"; +import utils from "./services/utils.ts"; +import appContext from "./components/app_context.ts"; +import server from "./services/server.ts"; interface ElectronProcess { type: string; @@ -10,11 +13,11 @@ interface ElectronProcess { } interface CustomGlobals { - isDesktop: boolean; - isMobile: boolean; + isDesktop: typeof utils.isDesktop; + isMobile: typeof utils.isMobile; device: "mobile" | "desktop"; - getComponentsByEl: (el: unknown) => unknown; - getHeaders: Promise>; + getComponentByEl: typeof appContext.getComponentByEl; + getHeaders: typeof server.getHeaders; getReferenceLinkTitle: (href: string) => Promise; getReferenceLinkTitleSync: (href: string) => string; getActiveContextNote: FNote; @@ -36,6 +39,7 @@ interface CustomGlobals { maxEntityChangeSyncIdAtLoad: number; assetPath: string; instanceName: string; + appCssNoteIds: string[]; } type RequireMethod = (moduleName: string) => any; From f15bebd330e522d8ff5bd14cfc894f4ac0be888d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 17:48:27 +0200 Subject: [PATCH 48/66] chore(client/ts): port services/image --- src/public/app/services/{image.js => image.ts} | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) rename src/public/app/services/{image.js => image.ts} (66%) diff --git a/src/public/app/services/image.js b/src/public/app/services/image.ts similarity index 66% rename from src/public/app/services/image.js rename to src/public/app/services/image.ts index f732e843c..deedc7c0e 100644 --- a/src/public/app/services/image.js +++ b/src/public/app/services/image.ts @@ -1,6 +1,7 @@ +import { t } from "./i18n.js"; import toastService from "./toast.js"; -function copyImageReferenceToClipboard($imageWrapper) { +function copyImageReferenceToClipboard($imageWrapper: JQuery) { try { $imageWrapper.attr('contenteditable', 'true'); selectImage($imageWrapper.get(0)); @@ -14,17 +15,21 @@ function copyImageReferenceToClipboard($imageWrapper) { } } finally { - window.getSelection().removeAllRanges(); + window.getSelection()?.removeAllRanges(); $imageWrapper.removeAttr('contenteditable'); } } -function selectImage(element) { +function selectImage(element: HTMLElement | undefined) { + if (!element) { + return; + } + const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(element); - selection.removeAllRanges(); - selection.addRange(range); + selection?.removeAllRanges(); + selection?.addRange(range); } export default { From 476ce0545af66dd424206a5eb0530dd556efccdb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 17:50:18 +0200 Subject: [PATCH 49/66] chore(client/ts): port services/import --- src/public/app/services/{import.js => import.ts} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename src/public/app/services/{import.js => import.ts} (92%) diff --git a/src/public/app/services/import.js b/src/public/app/services/import.ts similarity index 92% rename from src/public/app/services/import.js rename to src/public/app/services/import.ts index 6cc3aebe7..b63fde8de 100644 --- a/src/public/app/services/import.js +++ b/src/public/app/services/import.ts @@ -1,11 +1,11 @@ -import toastService from "./toast.js"; +import toastService, { ToastOptions } from "./toast.js"; import server from "./server.js"; import ws from "./ws.js"; import utils from "./utils.js"; import appContext from "../components/app_context.js"; import { t } from "./i18n.js"; -export async function uploadFiles(entityType, parentNoteId, files, options) { +export async function uploadFiles(entityType: string, parentNoteId: string, files: string[], options: Record) { if (!['notes', 'attachments'].includes(entityType)) { throw new Error(`Unrecognized import entity type '${entityType}'.`); } @@ -45,7 +45,7 @@ export async function uploadFiles(entityType, parentNoteId, files, options) { } } -function makeToast(id, message) { +function makeToast(id: string, message: string): ToastOptions { return { id: id, title: t("import.import-status"), From 03b6ac450d3bb1e2cd69508e2355c2257ce67db5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 17:55:22 +0200 Subject: [PATCH 50/66] chore(client/ts): port services/keyboard_actions --- src/public/app/components/app_context.ts | 2 +- ...eyboard_actions.js => keyboard_actions.ts} | 31 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) rename src/public/app/services/{keyboard_actions.js => keyboard_actions.ts} (74%) diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 5303acd08..6998914e8 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -37,7 +37,7 @@ export type TriggerData = { messages?: unknown[]; } | { ntxId: string; - notePath: string; + notePath?: string; } | { text: string; } | { diff --git a/src/public/app/services/keyboard_actions.js b/src/public/app/services/keyboard_actions.ts similarity index 74% rename from src/public/app/services/keyboard_actions.js rename to src/public/app/services/keyboard_actions.ts index d4f88db6e..786cf2605 100644 --- a/src/public/app/services/keyboard_actions.js +++ b/src/public/app/services/keyboard_actions.ts @@ -1,10 +1,18 @@ import server from "./server.js"; import appContext from "../components/app_context.js"; import shortcutService from "./shortcuts.js"; +import Component from "../components/component.js"; -const keyboardActionRepo = {}; +const keyboardActionRepo: Record = {}; -const keyboardActionsLoaded = server.get('keyboard-actions').then(actions => { +// TODO: Deduplicate with server. +interface Action { + actionName: string; + effectiveShortcuts: string[]; + scope: string; +} + +const keyboardActionsLoaded = server.get('keyboard-actions').then(actions => { actions = actions.filter(a => !!a.actionName); // filter out separators for (const action of actions) { @@ -20,13 +28,13 @@ async function getActions() { return await keyboardActionsLoaded; } -async function getActionsForScope(scope) { +async function getActionsForScope(scope: string) { const actions = await keyboardActionsLoaded; return actions.filter(action => action.scope === scope); } -async function setupActionsForElement(scope, $el, component) { +async function setupActionsForElement(scope: string, $el: JQuery, component: Component) { const actions = await getActionsForScope(scope); for (const action of actions) { @@ -44,7 +52,7 @@ getActionsForScope("window").then(actions => { } }); -async function getAction(actionName, silent = false) { +async function getAction(actionName: string, silent = false) { await keyboardActionsLoaded; const action = keyboardActionRepo[actionName]; @@ -61,9 +69,15 @@ async function getAction(actionName, silent = false) { return action; } -function updateDisplayedShortcuts($container) { +function updateDisplayedShortcuts($container: JQuery) { + //@ts-ignore + //TODO: each() does not support async callbacks. $container.find('kbd[data-command]').each(async (i, el) => { const actionName = $(el).attr('data-command'); + if (!actionName) { + return; + } + const action = await getAction(actionName, true); if (action) { @@ -75,8 +89,13 @@ function updateDisplayedShortcuts($container) { } }); + //@ts-ignore + //TODO: each() does not support async callbacks. $container.find('[data-trigger-command]').each(async (i, el) => { const actionName = $(el).attr('data-trigger-command'); + if (!actionName) { + return; + } const action = await getAction(actionName, true); if (action) { From 3d2d3b11064aefcb119d0f6f663c0c45cb02c64d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 18:00:36 +0200 Subject: [PATCH 51/66] chore(client/ts): port services/library_loader --- .../{library_loader.js => library_loader.ts} | 68 ++++++++++++------- 1 file changed, 42 insertions(+), 26 deletions(-) rename src/public/app/services/{library_loader.js => library_loader.ts} (79%) diff --git a/src/public/app/services/library_loader.js b/src/public/app/services/library_loader.ts similarity index 79% rename from src/public/app/services/library_loader.js rename to src/public/app/services/library_loader.ts index b5507d1d6..84afb2077 100644 --- a/src/public/app/services/library_loader.js +++ b/src/public/app/services/library_loader.ts @@ -2,9 +2,17 @@ import mimeTypesService from "./mime_types.js"; import optionsService from "./options.js"; import { getStylesheetUrl } from "./syntax_highlight.js"; -const CKEDITOR = {"js": ["libraries/ckeditor/ckeditor.js"]}; +interface Library { + js?: string[] | (() => string[]); + css?: string[]; +} -const CODE_MIRROR = { + +const CKEDITOR: Library = { + js: ["libraries/ckeditor/ckeditor.js"] +}; + +const CODE_MIRROR: Library = { js: [ "node_modules/codemirror/lib/codemirror.js", "node_modules/codemirror/addon/display/placeholder.js", @@ -26,9 +34,13 @@ const CODE_MIRROR = { ] }; -const ESLINT = {js: ["node_modules/eslint/bin/eslint.js"]}; +const ESLINT: Library = { + js: [ + "node_modules/eslint/bin/eslint.js" + ] +}; -const RELATION_MAP = { +const RELATION_MAP: Library = { js: [ "node_modules/jsplumb/dist/js/jsplumb.min.js", "node_modules/panzoom/dist/panzoom.min.js" @@ -38,26 +50,30 @@ const RELATION_MAP = { ] }; -const PRINT_THIS = {js: ["node_modules/print-this/printThis.js"]}; +const PRINT_THIS: Library = { + js: ["node_modules/print-this/printThis.js"] +}; -const CALENDAR_WIDGET = {css: ["stylesheets/calendar.css"]}; +const CALENDAR_WIDGET: Library = { + css: ["stylesheets/calendar.css"] +}; -const KATEX = { +const KATEX: Library = { js: [ "node_modules/katex/dist/katex.min.js", "node_modules/katex/dist/contrib/mhchem.min.js", "node_modules/katex/dist/contrib/auto-render.min.js" ], css: [ "node_modules/katex/dist/katex.min.css" ] }; -const WHEEL_ZOOM = { +const WHEEL_ZOOM: Library = { js: [ "node_modules/vanilla-js-wheel-zoom/dist/wheel-zoom.min.js"] }; -const FORCE_GRAPH = { +const FORCE_GRAPH: Library = { js: [ "node_modules/force-graph/dist/force-graph.min.js"] }; -const MERMAID = { +const MERMAID: Library = { js: [ "node_modules/mermaid/dist/mermaid.min.js" ] @@ -67,13 +83,13 @@ const MERMAID = { * The ELK extension of Mermaid.js, which supports more advanced layouts. * See https://www.npmjs.com/package/@mermaid-js/layout-elk for more information. */ -const MERMAID_ELK = { +const MERMAID_ELK: Library = { js: [ "libraries/mermaid-elk/elk.min.js" ] } -const EXCALIDRAW = { +const EXCALIDRAW: Library = { js: [ "node_modules/react/umd/react.production.min.js", "node_modules/react-dom/umd/react-dom.production.min.js", @@ -81,30 +97,30 @@ const EXCALIDRAW = { ] }; -const MARKJS = { +const MARKJS: Library = { js: [ "node_modules/mark.js/dist/jquery.mark.es6.min.js" ] }; -const I18NEXT = { +const I18NEXT: Library = { js: [ "node_modules/i18next/i18next.min.js", "node_modules/i18next-http-backend/i18nextHttpBackend.min.js" ] }; -const MIND_ELIXIR = { +const MIND_ELIXIR: Library = { js: [ "node_modules/mind-elixir/dist/MindElixir.iife.js", "node_modules/@mind-elixir/node-menu/dist/node-menu.umd.cjs" ] }; -const HIGHLIGHT_JS = { +const HIGHLIGHT_JS: Library = { js: () => { const mimeTypes = mimeTypesService.getMimeTypes(); - const scriptsToLoad = new Set(); + const scriptsToLoad = new Set(); scriptsToLoad.add("node_modules/@highlightjs/cdn-assets/highlight.min.js"); for (const mimeType of mimeTypes) { const id = mimeType.highlightJs; @@ -120,14 +136,14 @@ const HIGHLIGHT_JS = { } } - const currentTheme = optionsService.get("codeBlockTheme"); + const currentTheme = String(optionsService.get("codeBlockTheme")); loadHighlightingTheme(currentTheme); return Array.from(scriptsToLoad); } }; -async function requireLibrary(library) { +async function requireLibrary(library: Library) { if (library.css) { library.css.map(cssUrl => requireCss(cssUrl)); } @@ -139,18 +155,18 @@ async function requireLibrary(library) { } } -function unwrapValue(value) { +function unwrapValue(value: T | (() => T)) { if (typeof value === "function") { - return value(); + return (value as () => T)(); } return value; } // we save the promises in case of the same script being required concurrently multiple times -const loadedScriptPromises = {}; +const loadedScriptPromises: Record = {}; -async function requireScript(url) { +async function requireScript(url: string) { url = `${window.glob.assetPath}/${url}`; if (!loadedScriptPromises[url]) { @@ -164,7 +180,7 @@ async function requireScript(url) { await loadedScriptPromises[url]; } -async function requireCss(url, prependAssetPath = true) { +async function requireCss(url: string, prependAssetPath = true) { const cssLinks = Array .from(document.querySelectorAll('link')) .map(el => el.href); @@ -178,8 +194,8 @@ async function requireCss(url, prependAssetPath = true) { } } -let highlightingThemeEl = null; -function loadHighlightingTheme(theme) { +let highlightingThemeEl: JQuery | null = null; +function loadHighlightingTheme(theme: string) { if (!theme) { return; } From c6d04b50fb41d1496ac1109e8665d1ec2cfad0ad Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 19:26:12 +0200 Subject: [PATCH 52/66] chore(client/ts): fix build errors --- src/public/app/services/bundle.ts | 3 ++- src/public/app/services/library_loader.ts | 3 +-- src/public/app/services/render.ts | 6 +----- src/public/app/types.d.ts | 7 ++++--- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/public/app/services/bundle.ts b/src/public/app/services/bundle.ts index e3ba2c21a..071d89458 100644 --- a/src/public/app/services/bundle.ts +++ b/src/public/app/services/bundle.ts @@ -7,8 +7,9 @@ import { t } from "./i18n.js"; import { Entity } from "./frontend_script_api.js"; // TODO: Deduplicate with server. -interface Bundle { +export interface Bundle { script: string; + html: string; noteId: string; allNoteIds: string[]; } diff --git a/src/public/app/services/library_loader.ts b/src/public/app/services/library_loader.ts index 84afb2077..a3e79e8c9 100644 --- a/src/public/app/services/library_loader.ts +++ b/src/public/app/services/library_loader.ts @@ -2,12 +2,11 @@ import mimeTypesService from "./mime_types.js"; import optionsService from "./options.js"; import { getStylesheetUrl } from "./syntax_highlight.js"; -interface Library { +export interface Library { js?: string[] | (() => string[]); css?: string[]; } - const CKEDITOR: Library = { js: ["libraries/ckeditor/ckeditor.js"] }; diff --git a/src/public/app/services/render.ts b/src/public/app/services/render.ts index 34e5fc901..9a998b573 100644 --- a/src/public/app/services/render.ts +++ b/src/public/app/services/render.ts @@ -1,11 +1,7 @@ import server from "./server.js"; -import bundleService from "./bundle.js"; +import bundleService, { Bundle } from "./bundle.js"; import FNote from "../entities/fnote.js"; -interface Bundle { - html: string; -} - async function render(note: FNote, $el: JQuery) { const relations = note.getRelations('renderNote'); const renderNoteIds = relations diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 6e2f57ccf..8a509b71c 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -6,6 +6,7 @@ import { Suggestion } from "./services/note_autocomplete.ts"; import utils from "./services/utils.ts"; import appContext from "./components/app_context.ts"; import server from "./services/server.ts"; +import library_loader, { Library } from "./services/library_loader.ts"; interface ElectronProcess { type: string; @@ -21,8 +22,8 @@ interface CustomGlobals { getReferenceLinkTitle: (href: string) => Promise; getReferenceLinkTitleSync: (href: string) => string; getActiveContextNote: FNote; - requireLibrary: (library: string) => Promise; - ESLINT: { js: string[]; }; + requireLibrary: typeof library_loader.requireLibrary; + ESLINT: Library; appContext: AppContext; froca: Froca; treeCache: Froca; @@ -70,7 +71,7 @@ declare global { displayKey: "name" | "value" | "notePathTitle"; cache: boolean; source: (term: string, cb: AutoCompleteCallback) => void, - templates: { + templates?: { suggestion: (suggestion: Suggestion) => string | undefined } }; From a759c1fbd212f9d481bff64b6fb201a4326470f1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 22:37:19 +0200 Subject: [PATCH 53/66] chore(client/ts): port services/link --- src/public/app/services/{link.js => link.ts} | 93 ++++++++++++++------ src/public/app/services/utils.ts | 2 +- 2 files changed, 68 insertions(+), 27 deletions(-) rename src/public/app/services/{link.js => link.ts} (83%) diff --git a/src/public/app/services/link.js b/src/public/app/services/link.ts similarity index 83% rename from src/public/app/services/link.js rename to src/public/app/services/link.ts index b7e236be5..a4d6a6d6e 100644 --- a/src/public/app/services/link.js +++ b/src/public/app/services/link.ts @@ -4,19 +4,19 @@ import appContext from "../components/app_context.js"; import froca from "./froca.js"; import utils from "./utils.js"; -function getNotePathFromUrl(url) { +function getNotePathFromUrl(url: string) { const notePathMatch = /#(root[A-Za-z0-9_/]*)$/.exec(url); return notePathMatch === null ? null : notePathMatch[1]; } -async function getLinkIcon(noteId, viewMode) { +async function getLinkIcon(noteId: string, viewMode: ViewMode | undefined) { let icon; - if (viewMode === 'default') { + if (!viewMode || viewMode === 'default') { const note = await froca.getNote(noteId); - icon = note.getIcon(); + icon = note?.getIcon(); } else if (viewMode === 'source') { icon = 'bx bx-code-curly'; } else if (viewMode === 'attachments') { @@ -25,7 +25,24 @@ async function getLinkIcon(noteId, viewMode) { return icon; } -async function createLink(notePath, options = {}) { +type ViewMode = "default" | "source" | "attachments" | string; + +interface ViewScope { + viewMode?: ViewMode; + attachmentId?: string; +} + +interface CreateLinkOptions { + title?: string; + showTooltip?: boolean; + showNotePath?: boolean; + showNoteIcon?: boolean; + referenceLink?: boolean; + autoConvertToImage?: boolean; + viewScope?: ViewScope; +} + +async function createLink(notePath: string, options: CreateLinkOptions = {}) { if (!notePath || !notePath.trim()) { logError("Missing note path"); @@ -45,6 +62,12 @@ async function createLink(notePath, options = {}) { const autoConvertToImage = options.autoConvertToImage === undefined ? false : options.autoConvertToImage; const { noteId, parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(notePath); + if (!noteId) { + logError("Missing note ID"); + + return $("").text("[missing note]"); + } + const viewScope = options.viewScope || {}; const viewMode = viewScope.viewMode || 'default'; let linkTitle = options.title; @@ -54,19 +77,19 @@ async function createLink(notePath, options = {}) { const attachment = await froca.getAttachment(viewScope.attachmentId); linkTitle = attachment ? attachment.title : '[missing attachment]'; - } else { + } else if (noteId) { linkTitle = await treeService.getNoteTitle(noteId, parentNoteId); } } const note = await froca.getNote(noteId); - if (autoConvertToImage && ['image', 'canvas', 'mermaid'].includes(note.type) && viewMode === 'default') { - const encodedTitle = encodeURIComponent(linkTitle); + if (autoConvertToImage && (note?.type && ['image', 'canvas', 'mermaid'].includes(note.type)) && viewMode === 'default') { + const encodedTitle = encodeURIComponent(linkTitle || ""); return $("") .attr("src", `api/images/${noteId}/${encodedTitle}?${Math.random()}`) - .attr("alt", linkTitle); + .attr("alt", linkTitle || ""); } const $container = $(""); @@ -102,7 +125,7 @@ async function createLink(notePath, options = {}) { $container.append($noteLink); if (showNotePath) { - const resolvedPathSegments = await treeService.resolveNotePathToSegments(notePath); + const resolvedPathSegments = await treeService.resolveNotePathToSegments(notePath) || []; resolvedPathSegments.pop(); // Remove last element const resolvedPath = resolvedPathSegments.join("/"); @@ -118,7 +141,14 @@ async function createLink(notePath, options = {}) { return $container; } -function calculateHash({notePath, ntxId, hoistedNoteId, viewScope = {}}) { +interface CalculateHashOpts { + notePath: string; + ntxId?: string; + hoistedNoteId?: string; + viewScope: ViewScope; +} + +function calculateHash({notePath, ntxId, hoistedNoteId, viewScope = {}}: CalculateHashOpts) { notePath = notePath || ""; const params = [ ntxId ? { ntxId: ntxId } : null, @@ -129,9 +159,9 @@ function calculateHash({notePath, ntxId, hoistedNoteId, viewScope = {}}) { const paramStr = params.map(pair => { const name = Object.keys(pair)[0]; - const value = pair[name]; + const value = (pair as Record)[name]; - return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; + return `${encodeURIComponent(name)}=${encodeURIComponent(value || "")}`; }).join("&"); if (!notePath && !paramStr) { @@ -147,7 +177,7 @@ function calculateHash({notePath, ntxId, hoistedNoteId, viewScope = {}}) { return hash; } -function parseNavigationStateFromUrl(url) { +function parseNavigationStateFromUrl(url: string | undefined) { if (!url) { return {}; } @@ -164,7 +194,7 @@ function parseNavigationStateFromUrl(url) { return {}; } - const viewScope = { + const viewScope: ViewScope = { viewMode: 'default' }; let ntxId = null; @@ -184,7 +214,7 @@ function parseNavigationStateFromUrl(url) { } else if (name === 'searchString') { searchString = value; // supports triggering search from URL, e.g. #?searchString=blabla } else if (['viewMode', 'attachmentId'].includes(name)) { - viewScope[name] = value; + (viewScope as any)[name] = value; } else { console.warn(`Unrecognized hash parameter '${name}'.`); } @@ -201,14 +231,14 @@ function parseNavigationStateFromUrl(url) { }; } -function goToLink(evt) { - const $link = $(evt.target).closest("a,.block-link"); +function goToLink(evt: MouseEvent) { + const $link = $(evt.target as any).closest("a,.block-link"); const hrefLink = $link.attr('href') || $link.attr('data-href'); return goToLinkExt(evt, hrefLink, $link); } -function goToLinkExt(evt, hrefLink, $link) { +function goToLinkExt(evt: MouseEvent, hrefLink: string | undefined, $link: JQuery) { if (hrefLink?.startsWith("data:")) { return true; } @@ -230,7 +260,7 @@ function goToLinkExt(evt, hrefLink, $link) { if (openInNewTab) { appContext.tabManager.openTabWithNoteWithHoisting(notePath, {viewScope}); } else if (isLeftClick) { - const ntxId = $(evt.target).closest("[data-ntx-id]").attr("data-ntx-id"); + const ntxId = $(evt.target as any).closest("[data-ntx-id]").attr("data-ntx-id"); const noteContext = ntxId ? appContext.tabManager.getNoteContextById(ntxId) @@ -275,8 +305,8 @@ function goToLinkExt(evt, hrefLink, $link) { return true; } -function linkContextMenu(e) { - const $link = $(e.target).closest("a"); +function linkContextMenu(e: Event) { + const $link = $(e.target as any).closest("a"); const url = $link.attr("href") || $link.attr("data-href"); const { notePath, viewScope } = parseNavigationStateFromUrl(url); @@ -290,7 +320,7 @@ function linkContextMenu(e) { linkContextMenuService.openContextMenu(notePath, e, viewScope, null); } -async function loadReferenceLinkTitle($el, href = null) { +async function loadReferenceLinkTitle($el: JQuery, href: string | null | undefined = null) { const $link = $el[0].tagName === 'A' ? $el : $el.find("a"); href = href || $link.attr("href"); @@ -300,6 +330,11 @@ async function loadReferenceLinkTitle($el, href = null) { } const {noteId, viewScope} = parseNavigationStateFromUrl(href); + if (!noteId) { + console.warn("Missing note ID."); + return; + } + const note = await froca.getNote(noteId, true); if (note) { @@ -312,11 +347,13 @@ async function loadReferenceLinkTitle($el, href = null) { if (note) { const icon = await getLinkIcon(noteId, viewScope.viewMode); - $el.prepend($("").addClass(icon)); + if (icon) { + $el.prepend($("").addClass(icon)); + } } } -async function getReferenceLinkTitle(href) { +async function getReferenceLinkTitle(href: string) { const {noteId, viewScope} = parseNavigationStateFromUrl(href); if (!noteId) { return "[missing note]"; @@ -336,7 +373,7 @@ async function getReferenceLinkTitle(href) { } } -function getReferenceLinkTitleSync(href) { +function getReferenceLinkTitleSync(href: string) { const {noteId, viewScope} = parseNavigationStateFromUrl(href); if (!noteId) { return "[missing note]"; @@ -360,7 +397,11 @@ function getReferenceLinkTitleSync(href) { } } +// TODO: Check why the event is not supported. +//@ts-ignore $(document).on('click', "a", goToLink); +// TODO: Check why the event is not supported. +//@ts-ignore $(document).on('auxclick', "a", goToLink); // to handle the middle button $(document).on('contextmenu', 'a', linkContextMenu); $(document).on('dblclick', "a", e => { diff --git a/src/public/app/services/utils.ts b/src/public/app/services/utils.ts index 47bc2c1e9..2a65e63c9 100644 --- a/src/public/app/services/utils.ts +++ b/src/public/app/services/utils.ts @@ -99,7 +99,7 @@ function isMac() { return navigator.platform.indexOf('Mac') > -1; } -function isCtrlKey(evt: KeyboardEvent) { +function isCtrlKey(evt: KeyboardEvent | MouseEvent) { return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey); } From f4c73d45c7283f1976fd5230dbed152d6177948d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 22:37:38 +0200 Subject: [PATCH 54/66] chore(client/ts): port services/mac_init --- src/public/app/services/{mac_init.js => mac_init.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/public/app/services/{mac_init.js => mac_init.ts} (96%) diff --git a/src/public/app/services/mac_init.js b/src/public/app/services/mac_init.ts similarity index 96% rename from src/public/app/services/mac_init.js rename to src/public/app/services/mac_init.ts index 10fba8cbb..259b412f1 100644 --- a/src/public/app/services/mac_init.js +++ b/src/public/app/services/mac_init.ts @@ -15,7 +15,7 @@ function init() { } } -function exec(cmd) { +function exec(cmd: string) { document.execCommand(cmd); return false; From c93fcc6988e23c3d15764da2dabcf264198cfed5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 22:39:28 +0200 Subject: [PATCH 55/66] chore(client/ts): port services/mermaid --- src/public/app/services/{mermaid.js => mermaid.ts} | 2 +- src/public/app/types.d.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) rename src/public/app/services/{mermaid.js => mermaid.ts} (93%) diff --git a/src/public/app/services/mermaid.js b/src/public/app/services/mermaid.ts similarity index 93% rename from src/public/app/services/mermaid.js rename to src/public/app/services/mermaid.ts index 152f14252..e553e89e2 100644 --- a/src/public/app/services/mermaid.js +++ b/src/public/app/services/mermaid.ts @@ -11,7 +11,7 @@ let elkLoaded = false; * * @param mermaidContent the plain text of the mermaid diagram, potentially including a frontmatter. */ -export async function loadElkIfNeeded(mermaidContent) { +export async function loadElkIfNeeded(mermaidContent: string) { if (elkLoaded) { // Exit immediately since the ELK library is already loaded. return; diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 8a509b71c..147ca34d6 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -128,8 +128,20 @@ declare global { securityLevel: "antiscript" }): void; render(selector: string, data: string); + } + interface MermaidLoader { + } var mermaid: { mermaidAPI: MermaidApi; + registerLayoutLoaders(loader: MermaidLoader); + parse(content: string, opts: { + suppressErrors: true + }): { + config: { + layout: string; + } + } }; + var MERMAID_ELK: MermaidLoader; } From 7565fdfd5c1a92b3f7a505d90ece39b62f060210 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 22:45:39 +0200 Subject: [PATCH 56/66] chore(client/ts): port services/mime_types --- .../services/{mime_types.js => mime_types.ts} | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) rename src/public/app/services/{mime_types.js => mime_types.ts} (89%) diff --git a/src/public/app/services/mime_types.js b/src/public/app/services/mime_types.ts similarity index 89% rename from src/public/app/services/mime_types.js rename to src/public/app/services/mime_types.ts index adb863673..44e3e72d8 100644 --- a/src/public/app/services/mime_types.js +++ b/src/public/app/services/mime_types.ts @@ -9,7 +9,21 @@ const MIME_TYPE_AUTO = "text-x-trilium-auto"; * For highlight.js-supported languages, see https://github.com/highlightjs/highlight.js/blob/main/SUPPORTED_LANGUAGES.md. */ -const MIME_TYPES_DICT = [ +interface MimeTypeDefinition { + default?: boolean; + title: string; + mime: string; + /** The name of the language/mime type as defined by highlight.js (or one of the aliases), in order to be used for syntax highlighting such as inside code blocks. */ + highlightJs?: string; + /** If specified, will load the corresponding highlight.js file from the `libraries/highlightjs/${id}.js` instead of `node_modules/@highlightjs/cdn-assets/languages/${id}.min.js`. */ + highlightJsSource?: "libraries"; +} + +interface MimeType extends MimeTypeDefinition { + enabled: boolean +} + +const MIME_TYPES_DICT: MimeTypeDefinition[] = [ { default: true, title: "Plain text", mime: "text/plain", highlightJs: "plaintext" }, { title: "APL", mime: "text/apl" }, { title: "ASN.1", mime: "text/x-ttcn-asn" }, @@ -170,10 +184,10 @@ const MIME_TYPES_DICT = [ { title: "Z80", mime: "text/x-z80" } ]; -let mimeTypes = null; +let mimeTypes: MimeType[] | null = null; function loadMimeTypes() { - mimeTypes = JSON.parse(JSON.stringify(MIME_TYPES_DICT)); // clone + mimeTypes = JSON.parse(JSON.stringify(MIME_TYPES_DICT)) as MimeType[]; // clone const enabledMimeTypes = options.getJson('codeNotesMimeTypes') || MIME_TYPES_DICT.filter(mt => mt.default).map(mt => mt.mime); @@ -183,32 +197,34 @@ function loadMimeTypes() { } } -function getMimeTypes() { +function getMimeTypes(): MimeType[] { if (mimeTypes === null) { loadMimeTypes(); } - return mimeTypes; + return mimeTypes as MimeType[]; } -let mimeToHighlightJsMapping = null; +let mimeToHighlightJsMapping: Record | null = null; /** * Obtains the corresponding language tag for highlight.js for a given MIME type. * * The mapping is built the first time this method is built and then the results are cached for better performance. * - * @param {string} mimeType The MIME type of the code block, in the CKEditor-normalized format (e.g. `text-c-src` instead of `text/c-src`). + * @param mimeType The MIME type of the code block, in the CKEditor-normalized format (e.g. `text-c-src` instead of `text/c-src`). * @returns the corresponding highlight.js tag, for example `c` for `text-c-src`. */ -function getHighlightJsNameForMime(mimeType) { +function getHighlightJsNameForMime(mimeType: string) { if (!mimeToHighlightJsMapping) { const mimeTypes = getMimeTypes(); mimeToHighlightJsMapping = {}; for (const mimeType of mimeTypes) { // The mime stored by CKEditor is text-x-csrc instead of text/x-csrc so we keep this format for faster lookup. const normalizedMime = normalizeMimeTypeForCKEditor(mimeType.mime); - mimeToHighlightJsMapping[normalizedMime] = mimeType.highlightJs; + if (mimeType.highlightJs) { + mimeToHighlightJsMapping[normalizedMime] = mimeType.highlightJs; + } } } @@ -219,10 +235,10 @@ function getHighlightJsNameForMime(mimeType) { * Given a MIME type in the usual format (e.g. `text/csrc`), it returns a MIME type that can be passed down to the CKEditor * code plugin. * - * @param {string} mimeType The MIME type to normalize, in the usual format (e.g. `text/c-src`). + * @param mimeType The MIME type to normalize, in the usual format (e.g. `text/c-src`). * @returns the normalized MIME type (e.g. `text-c-src`). */ -function normalizeMimeTypeForCKEditor(mimeType) { +function normalizeMimeTypeForCKEditor(mimeType: string) { return mimeType.toLowerCase() .replace(/[\W_]+/g,"-"); } From 88d5aa973c9c722f192bad9c675ed6616a6c0267 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 21 Dec 2024 23:06:40 +0200 Subject: [PATCH 57/66] chore(client/ts): port widgets/note_type_chooser --- src/public/app/services/note_types.ts | 16 ++++-- ...e_type_chooser.js => note_type_chooser.ts} | 55 +++++++++++++------ 2 files changed, 49 insertions(+), 22 deletions(-) rename src/public/app/widgets/dialogs/{note_type_chooser.js => note_type_chooser.ts} (75%) diff --git a/src/public/app/services/note_types.ts b/src/public/app/services/note_types.ts index 2526821a8..472004dab 100644 --- a/src/public/app/services/note_types.ts +++ b/src/public/app/services/note_types.ts @@ -2,17 +2,21 @@ import server from "./server.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; -type NoteTypeItem = { +interface NoteTypeSeparator { + title: "----" +} + +export interface NoteType { title: string; - command: string; + command?: string; type: string; uiIcon: string; templateNoteId?: string; -} | { - title: "----" -}; +} -async function getNoteTypeItems(command: string) { +type NoteTypeItem = NoteType | NoteTypeSeparator; + +async function getNoteTypeItems(command?: string) { const items: NoteTypeItem[] = [ { title: t("note_types.text"), command: command, type: "text", uiIcon: "bx bx-note" }, { title: t("note_types.code"), command: command, type: "code", uiIcon: "bx bx-code" }, diff --git a/src/public/app/widgets/dialogs/note_type_chooser.js b/src/public/app/widgets/dialogs/note_type_chooser.ts similarity index 75% rename from src/public/app/widgets/dialogs/note_type_chooser.js rename to src/public/app/widgets/dialogs/note_type_chooser.ts index 93fe69222..d75e8c349 100644 --- a/src/public/app/widgets/dialogs/note_type_chooser.js +++ b/src/public/app/widgets/dialogs/note_type_chooser.ts @@ -1,5 +1,5 @@ import { t } from "../../services/i18n.js"; -import noteTypesService from "../../services/note_types.js"; +import noteTypesService, { NoteType } from "../../services/note_types.js"; import BasicWidget from "../basic_widget.js"; const TPL = ` @@ -41,9 +41,25 @@ const TPL = ` `; +interface CallbackData { + success: boolean; + noteType?: string; + templateNoteId?: string; +} + +type Callback = (data: CallbackData) => void; + export default class NoteTypeChooserDialog extends BasicWidget { - constructor(props) { - super(props); + + private resolve: Callback | null; + private dropdown!: bootstrap.Dropdown; + private modal!: JQuery; + private $noteTypeDropdown!: JQuery; + private $originalFocused: JQuery | null; + private $originalDialog: JQuery | null; + + constructor(props: {}) { + super(); this.resolve = null; this.$originalFocused = null; // element focused before the dialog was opened, so we can return to it afterward @@ -51,10 +67,14 @@ export default class NoteTypeChooserDialog extends BasicWidget { } doRender() { - this.$widget = $(TPL); + this.$widget = $(TPL); + // TODO: Remove once we import bootstrap the right way + //@ts-ignore this.modal = bootstrap.Modal.getOrCreateInstance(this.$widget); this.$noteTypeDropdown = this.$widget.find(".note-type-dropdown"); + // TODO: Remove once we import bootstrap the right way + //@ts-ignore this.dropdown = bootstrap.Dropdown.getOrCreateInstance(this.$widget.find(".note-type-dropdown-trigger")); this.$widget.on("hidden.bs.modal", () => { @@ -88,13 +108,15 @@ export default class NoteTypeChooserDialog extends BasicWidget { this.$noteTypeDropdown.parent().on('hide.bs.dropdown', e => { // prevent closing dropdown by clicking outside + // TODO: Check if this actually works. + //@ts-ignore if (e.clickEvent) { e.preventDefault(); } }); } - async chooseNoteTypeEvent({ callback }) { + async chooseNoteTypeEvent({ callback }: { callback: Callback }) { this.$originalFocused = $(':focus'); const noteTypes = await noteTypesService.getNoteTypeItems(); @@ -104,13 +126,12 @@ export default class NoteTypeChooserDialog extends BasicWidget { for (const noteType of noteTypes) { if (noteType.title === '----') { this.$noteTypeDropdown.append($('