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 diff --git a/bin/copy-dist.ts b/bin/copy-dist.ts index 093a3a375..9e272d222 100644 --- a/bin/copy-dist.ts +++ b/bin/copy-dist.ts @@ -29,7 +29,7 @@ const copy = async () => { fs.copySync(path.join("build", srcFile), destFile, { recursive: true }); } - const filesToCopy = ["config-sample.ini"]; + const filesToCopy = ["config-sample.ini", "tsconfig.webpack.json"]; for (const file of filesToCopy) { log(`Copying ${file}`); await fs.copy(file, path.join(DEST_DIR, file)); diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 6372d43ce..549f465bf 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -14,6 +14,10 @@ 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"; +import { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; +import { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; +import { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js"; interface Layout { getRootWidget: (appContext: AppContext) => RootWidget; @@ -27,13 +31,72 @@ interface BeforeUploadListener extends Component { beforeUnloadEvent(): boolean; } -interface TriggerData { - noteId?: string; - noteIds?: string[]; - messages?: unknown[]; - callback?: () => void; +interface CommandData { + ntxId?: string; } +type CommandMappings = { + "api-log-messages": CommandData; + focusOnDetail: Required; + searchNotes: CommandData & { + searchString: string | undefined; + }; + showDeleteNotesDialog: CommandData & { + branchIdsToDelete: string[]; + callback: (value: ResolveOptions) => void; + forceDeleteAllClones: boolean; + }; + showConfirmDeleteNoteBoxWithNoteDialog: ConfirmWithTitleOptions; + openedFileUpdated: CommandData & { + entityType: string; + entityId: string; + lastModifiedMs: number; + filePath: string; + }; + focusAndSelectTitle: CommandData & { + isNewNote: boolean; + }; + showPromptDialog: PromptDialogOptions; + showInfoDialog: ConfirmWithMessageOptions; + showConfirmDialog: ConfirmWithMessageOptions; + openNewNoteSplit: CommandData & { + ntxId: string; + notePath: string; + }; + executeInActiveNoteDetailWidget: CommandData & { + callback: (value: NoteDetailWidget | PromiseLike) => void + }; + addTextToActiveEditor: CommandData & { + text: string; + }; + + importMarkdownInline: CommandData; + showPasswordNotSet: CommandData; + showProtectedSessionPasswordDialog: CommandData; + closeProtectedSessionPasswordDialog: CommandData; +} + +type EventMappings = { + initialRenderComplete: {}; + frocaReloaded: {}; + protectedSessionStarted: {}; + notesReloaded: { + noteIds: string[]; + }; + refreshIncludedNote: { + noteId: string; + }; + apiLogMessages: { + noteId: string; + messages: string[]; + }; +} + +type CommandAndEventMappings = (CommandMappings & EventMappings); + +export type CommandNames = keyof CommandMappings; +type EventNames = keyof EventMappings; + class AppContext extends Component { isMainWindow: boolean; @@ -127,11 +190,15 @@ class AppContext extends Component { this.triggerEvent('initialRenderComplete'); } - triggerEvent(name: string, data: TriggerData = {}) { + // TODO: Remove ignore once all commands are mapped out. + //@ts-ignore + triggerEvent(name: K, data: CommandAndEventMappings[K] = {}) { return this.handleEvent(name, data); } - triggerCommand(name: string, data: TriggerData = {}) { + // TODO: Remove ignore once all commands are mapped out. + //@ts-ignore + triggerCommand(name: K, data: CommandMappings[K] = {}) { for (const executor of this.components) { const fun = (executor as any)[`${name}Command`]; @@ -144,7 +211,7 @@ class AppContext extends Component { // in the component tree to communicate with each other console.debug(`Unhandled command ${name}, converting to event.`); - return this.triggerEvent(name, data); + return this.triggerEvent(name, data as CommandAndEventMappings[K]); } getComponentByEl(el: HTMLElement) { 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/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/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/attribute_autocomplete.js b/src/public/app/services/attribute_autocomplete.ts similarity index 83% rename from src/public/app/services/attribute_autocomplete.js rename to src/public/app/services/attribute_autocomplete.ts index 761d5dbeb..041129974 100644 --- a/src/public/app/services/attribute_autocomplete.js +++ 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'), @@ -20,7 +28,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); @@ -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 @@ -52,7 +60,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) { 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[] - 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); diff --git a/src/public/app/services/attribute_renderer.js b/src/public/app/services/attribute_renderer.ts similarity index 84% rename from src/public/app/services/attribute_renderer.js rename to src/public/app/services/attribute_renderer.ts index 8916665a3..b5f8477ec 100644 --- a/src/public/app/services/attribute_renderer.js +++ 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 = $(""); @@ -20,7 +22,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}`); @@ -29,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; } @@ -47,7 +53,7 @@ function formatValue(val) { } } -async function createLink(noteId) { +async function createLink(noteId: string) { const note = await froca.getNote(noteId); if (!note) { @@ -61,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++) { @@ -89,7 +95,7 @@ const HIDDEN_ATTRIBUTES = [ 'viewType' ]; -async function renderNormalAttributes(note) { +async function renderNormalAttributes(note: FNote) { const promotedDefinitionAttributes = note.getPromotedDefinitionAttributes(); let attrs = note.getAttributes(); diff --git a/src/public/app/services/attributes.js b/src/public/app/services/attributes.ts similarity index 79% rename from src/public/app/services/attributes.js rename to src/public/app/services/attributes.ts index 7267d9bb8..e8dbcb89e 100644 --- a/src/public/app/services/attributes.js +++ 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)) { 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..6eb5c07ff 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/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; } 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..071d89458 100644 --- a/src/public/app/services/bundle.js +++ b/src/public/app/services/bundle.ts @@ -4,9 +4,22 @@ 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. +export interface Bundle { + script: string; + html: 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 +27,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 +51,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 +68,7 @@ class WidgetsByParent { this.byParent[widget.parentWidget].push(widget); } - get(parentName) { + get(parentName: string) { if (!this.byParent[parentName]) { return []; } @@ -62,12 +77,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 +95,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 +103,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/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) { 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/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);
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/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;
     }
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/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/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..1cbab4a0f 100644
--- a/src/public/app/services/frontend_script_api.js
+++ b/src/public/app/services/frontend_script_api.ts
@@ -15,6 +15,11 @@ 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,434 @@ import dialogService from "./dialog.js";
  * @var {FrontendScriptApi} 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(startNote, currentNote, originEntity = null, $container = null) { +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; +} + +export interface Entity { + noteId: string; +} + +type Func = ((...args: unknown[]) => unknown) | string; + +interface Api { /** * Container of all the rendered script content - * @type {jQuery} * */ - this.$container = $container; + $container: JQuery | null; /** * Note where the script started executing, i.e., the (event) entrypoint of the current script execution. - * @type {FNote} */ - this.startNote = startNote; + startNote: FNote; /** * Note where the script is currently executing, i.e. the note where the currently executing source code is written. - * @type {FNote} */ - this.currentNote = currentNote; + currentNote: FNote; /** * Entity whose event triggered this execution. - * @type {object|null} */ - this.originEntity = originEntity; + originEntity: unknown | null; /** * day.js library for date manipulation. * See {@link https://day.js.org} for documentation * @see https://day.js.org - * @type {dayjs} */ - this.dayjs = dayjs; + dayjs: typeof window.dayjs; - /** @type {RightPanelWidget} */ - this.RightPanelWidget = RightPanelWidget; - - /** @type {NoteContextAwareWidget} */ - this.NoteContextAwareWidget = NoteContextAwareWidget; - - /** @type {BasicWidget} */ - this.BasicWidget = BasicWidget; + RightPanelWidget: typeof RightPanelWidget; + NoteContextAwareWidget: typeof NoteContextAwareWidget; + BasicWidget: typeof BasicWidget; /** * Activates note in the tree and in the note detail. * - * @method - * @param {string} notePath (or noteId) - * @returns {Promise} + * @param notePath (or noteId) */ - this.activateNote = async notePath => { - await appContext.tabManager.getActiveContext().setNote(notePath); - }; + activateNote(notePath: string): Promise; /** * Activates newly created note. Compared to this.activateNote() also makes sure that frontend has been fully synced. * - * @param {string} notePath (or noteId) - * @returns {Promise} + * @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: typeof appContext.triggerCommand; + + /** + * Trigger event. This is a very low-level API which should be avoided if possible. + */ + triggerEvent: typeof appContext.triggerEvent; + + /** + * 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);

+ */ +function FrontendScriptApi(this: Api, startNote: FNote, currentNote: FNote, originEntity: Entity | null = null, $container: JQuery | null = null) { + + this.$container = $container; + this.startNote = startNote; + this.currentNote = currentNote; + this.originEntity = originEntity; + this.dayjs = dayjs; + this.RightPanelWidget = RightPanelWidget; + this.NoteContextAwareWidget = NoteContextAwareWidget; + this.BasicWidget = BasicWidget; + + this.activateNote = async notePath => { + await appContext.tabManager.getActiveContext().setNote(notePath); + }; + this.activateNewNote = async notePath => { await ws.waitForMaxKnownEntityChangeId(); @@ -100,14 +468,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 +478,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 +491,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 +518,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 +543,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 +636,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; @@ -653,4 +669,6 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain }; } -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/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/i18n.js b/src/public/app/services/i18n.ts similarity index 89% rename from src/public/app/services/i18n.js rename to src/public/app/services/i18n.ts index 7e9be3a09..3d82c0e1d 100644 --- a/src/public/app/services/i18n.js +++ 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) 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 { 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"), diff --git a/src/public/app/services/keyboard_actions.js b/src/public/app/services/keyboard_actions.ts similarity index 72% rename from src/public/app/services/keyboard_actions.js rename to src/public/app/services/keyboard_actions.ts index d4f88db6e..630ec03a9 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 appContext, { CommandNames } 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: CommandNames; + 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) { 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..a3e79e8c9 100644 --- a/src/public/app/services/library_loader.js +++ b/src/public/app/services/library_loader.ts @@ -2,9 +2,16 @@ import mimeTypesService from "./mime_types.js"; import optionsService from "./options.js"; import { getStylesheetUrl } from "./syntax_highlight.js"; -const CKEDITOR = {"js": ["libraries/ckeditor/ckeditor.js"]}; +export 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 +33,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 +49,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 +82,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 +96,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 +135,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 +154,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 +179,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 +193,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; } 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/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/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; 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/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,"-"); } 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..d83d83f62 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/services/note_create.js b/src/public/app/services/note_create.ts similarity index 64% rename from src/public/app/services/note_create.js rename to src/public/app/services/note_create.ts index cc3bbb42f..b44bbcabb 100644 --- a/src/public/app/services/note_create.js +++ b/src/public/app/services/note_create.ts @@ -6,8 +6,41 @@ import froca from "./froca.js"; import treeService from "./tree.js"; import toastService from "./toast.js"; import { t } from "./i18n.js"; +import FNote from "../entities/fnote.js"; +import FBranch from "../entities/fbranch.js"; +import { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js"; -async function createNote(parentNotePath, options = {}) { +interface CreateNoteOpts { + isProtected?: boolean; + saveSelection?: boolean; + title?: string | null; + content?: string | null; + type?: string; + mime?: string; + templateNoteId?: string; + activate?: boolean; + focus?: "title" | "content"; + target?: string; + targetBranchId?: string; + textEditor?: { + // TODO: Replace with interface once note_context.js is converted. + getSelectedHtml(): string; + removeSelection(): void; + } +} + +interface Response { + // TODO: Deduplicate with server once we have client/server architecture. + note: FNote; + branch: FBranch; +} + +interface DuplicateResponse { + // TODO: Deduplicate with server once we have client/server architecture. + note: FNote; +} + +async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) { options = Object.assign({ activate: true, focus: 'title', @@ -24,7 +57,7 @@ async function createNote(parentNotePath, options = {}) { options.saveSelection = false; } - if (options.saveSelection) { + if (options.saveSelection && options.textEditor) { [options.title, options.content] = parseSelectedHtml(options.textEditor.getSelectedHtml()); } @@ -38,7 +71,7 @@ async function createNote(parentNotePath, options = {}) { C-->D;` } - const {note, branch} = await server.post(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, { + const {note, branch} = await server.post(`notes/${parentNoteId}/children?target=${options.target}&targetBranchId=${options.targetBranchId || ""}`, { title: options.title, content: options.content || "", isProtected: options.isProtected, @@ -49,7 +82,7 @@ async function createNote(parentNotePath, options = {}) { if (options.saveSelection) { // we remove the selection only after it was saved to server to make sure we don't lose anything - options.textEditor.removeSelection(); + options.textEditor?.removeSelection(); } await ws.waitForMaxKnownEntityChangeId(); @@ -76,12 +109,14 @@ async function createNote(parentNotePath, options = {}) { } async function chooseNoteType() { - return new Promise(res => { + return new Promise(res => { + // TODO: Remove ignore after callback for chooseNoteType is defined in app_context.ts + //@ts-ignore appContext.triggerCommand("chooseNoteType", {callback: res}); }); } -async function createNoteWithTypePrompt(parentNotePath, options = {}) { +async function createNoteWithTypePrompt(parentNotePath: string, options: CreateNoteOpts = {}) { const {success, noteType, templateNoteId} = await chooseNoteType(); if (!success) { @@ -95,12 +130,16 @@ async function createNoteWithTypePrompt(parentNotePath, options = {}) { } /* If the first element is heading, parse it out and use it as a new heading. */ -function parseSelectedHtml(selectedHtml) { +function parseSelectedHtml(selectedHtml: string) { const dom = $.parseHTML(selectedHtml); + // TODO: tagName and outerHTML appear to be missing. + //@ts-ignore if (dom.length > 0 && dom[0].tagName && dom[0].tagName.match(/h[1-6]/i)) { const title = $(dom[0]).text(); // remove the title from content (only first occurrence) + // TODO: tagName and outerHTML appear to be missing. + //@ts-ignore const content = selectedHtml.replace(dom[0].outerHTML, ""); return [title, content]; @@ -110,9 +149,9 @@ function parseSelectedHtml(selectedHtml) { } } -async function duplicateSubtree(noteId, parentNotePath) { +async function duplicateSubtree(noteId: string, parentNotePath: string) { const parentNoteId = treeService.getNoteIdFromUrl(parentNotePath); - const {note} = await server.post(`notes/${noteId}/duplicate/${parentNoteId}`); + const {note} = await server.post(`notes/${noteId}/duplicate/${parentNoteId}`); await ws.waitForMaxKnownEntityChangeId(); @@ -120,7 +159,7 @@ async function duplicateSubtree(noteId, parentNotePath) { activeNoteContext.setNote(`${parentNotePath}/${note.noteId}`); const origNote = await froca.getNote(noteId); - toastService.showMessage(t("note_create.duplicated", { title: origNote.title })); + toastService.showMessage(t("note_create.duplicated", { title: origNote?.title })); } export default { 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); diff --git a/src/public/app/services/note_types.js b/src/public/app/services/note_types.ts similarity index 81% rename from src/public/app/services/note_types.js rename to src/public/app/services/note_types.ts index 8bc30af6b..472004dab 100644 --- a/src/public/app/services/note_types.js +++ b/src/public/app/services/note_types.ts @@ -2,8 +2,22 @@ import server from "./server.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; -async function getNoteTypeItems(command) { - const items = [ +interface NoteTypeSeparator { + title: "----" +} + +export interface NoteType { + title: string; + command?: string; + type: string; + uiIcon: string; + templateNoteId?: 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" }, { title: t("note_types.saved-search"), command: command, type: "search", uiIcon: "bx bx-file-find" }, @@ -17,7 +31,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) { diff --git a/src/public/app/services/options.ts b/src/public/app/services/options.ts index 4f7e15f79..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 | number; +type OptionValue = number | string; class Options { initializedPromise: Promise; 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/render.js b/src/public/app/services/render.ts similarity index 72% rename from src/public/app/services/render.js rename to src/public/app/services/render.ts index e7ecf8d28..9a998b573 100644 --- a/src/public/app/services/render.js +++ b/src/public/app/services/render.ts @@ -1,7 +1,8 @@ import server from "./server.js"; -import bundleService from "./bundle.js"; +import bundleService, { Bundle } from "./bundle.js"; +import FNote from "../entities/fnote.js"; -async function render(note, $el) { +async function render(note: FNote, $el: JQuery) { const relations = note.getRelations('renderNote'); const renderNoteIds = relations .map(rel => rel.value) @@ -10,7 +11,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); 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/services/script_context.js b/src/public/app/services/script_context.ts similarity index 64% rename from src/public/app/services/script_context.js rename to src/public/app/services/script_context.ts index f47380704..7d76c12a7 100644 --- a/src/public/app/services/script_context.js +++ b/src/public/app/services/script_context.ts @@ -1,21 +1,25 @@ -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, allNoteIds, originEntity = null, $container = null) { - const modules = {}; +async function ScriptContext(startNoteId: string, allNoteIds: string[], originEntity: Entity | null = 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/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); 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; } 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/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")); diff --git a/src/public/app/services/syntax_highlight.js b/src/public/app/services/syntax_highlight.ts similarity index 90% rename from src/public/app/services/syntax_highlight.js rename to src/public/app/services/syntax_highlight.ts index c9f9449eb..68ca07ced 100644 --- a/src/public/app/services/syntax_highlight.js +++ 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)) {
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;
diff --git a/src/public/app/services/tree.ts b/src/public/app/services/tree.ts
index 3a5cc9952..f2490278e 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;
     }
 }
 
@@ -144,7 +149,7 @@ function getParentProtectedStatus(node: Node) {
     return hoistedNoteService.isHoistedNode(node) ? false : node.getParent().data.isProtected;
 }
 
-function getNoteIdFromUrl(urlOrNotePath: string) {
+function getNoteIdFromUrl(urlOrNotePath: string | undefined) {
     if (!urlOrNotePath) {
         return null;
     }
diff --git a/src/public/app/services/utils.ts b/src/public/app/services/utils.ts
index 4addc1d1f..13539a635 100644
--- a/src/public/app/services/utils.ts
+++ b/src/public/app/services/utils.ts
@@ -1,5 +1,4 @@
 import dayjs from "dayjs";
-import { Modal } from "bootstrap";
 
 function reloadFrontendApp(reason?: string) {
     if (reason) {
@@ -99,7 +98,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);
 }
@@ -138,8 +137,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);
@@ -205,7 +204,9 @@ function getMimeTypeClass(mime: string) {
 
 function closeActiveDialog() {
     if (glob.activeDialog) {
-        Modal.getOrCreateInstance(glob.activeDialog[0]).hide();
+        // TODO: Fix once we use proper ES imports.
+        //@ts-ignore
+        bootstrap.Modal.getOrCreateInstance(glob.activeDialog[0]).hide();
         glob.activeDialog = null;
     }
 }
@@ -249,7 +250,9 @@ async function openDialog($dialog: JQuery, closeActDialog = true) {
     }
 
     saveFocusedElement();
-    Modal.getOrCreateInstance($dialog[0]).show();
+    // TODO: Fix once we use proper ES imports.
+    //@ts-ignore
+    bootstrap.Modal.getOrCreateInstance($dialog[0]).show();
 
     $dialog.on('hidden.bs.modal', () => {
         $(".aa-input").autocomplete("close");
@@ -350,7 +353,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/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[] = [];
diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts
index f80a3fbec..147ca34d6 100644
--- a/src/public/app/types.d.ts
+++ b/src/public/app/types.d.ts
@@ -1,4 +1,12 @@
-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";
+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;
@@ -6,16 +14,16 @@ 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;
-    requireLibrary: (library: string) => Promise;
-    ESLINT: { js: string[]; };
+    requireLibrary: typeof library_loader.requireLibrary;
+    ESLINT: Library;
     appContext: AppContext;
     froca: Froca;
     treeCache: Froca;
@@ -30,6 +38,9 @@ interface CustomGlobals {
     isMainWindow: boolean;
     maxEntityChangeIdAtLoad: number;
     maxEntityChangeSyncIdAtLoad: number;
+    assetPath: string;
+    instanceName: string;
+    appCssNoteIds: string[];
 }
 
 type RequireMethod = (moduleName: string) => any;
@@ -37,19 +48,100 @@ type RequireMethod = (moduleName: string) => any;
 declare global {
     interface Window {
         logError(message: string);
-        logInfo(message: string);
+        logInfo(message: string);        
     
         process?: ElectronProcess;
         glob?: CustomGlobals;
     }
 
-    interface JQuery {
-        autocomplete: (action: "close") => void;
+    interface AutoCompleteConfig {
+        appendTo?: HTMLElement | null;
+        hint?: boolean;
+        openOnFocus?: boolean;
+        minLength?: number;
+        tabAutocomplete?: boolean;
+        autoselect?: boolean;
+        dropdownMenuContainer?: HTMLElement;
+        debug?: boolean;
     }
 
-    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;
+    type AutoCompleteCallback = (values: AutoCompleteCallbackArg[]) => void;
+
+    interface AutoCompleteArg {
+        displayKey: "name" | "value" | "notePathTitle";
+        cache: boolean;
+        source: (term: string, cb: AutoCompleteCallback) => void,
+        templates?: {
+            suggestion: (suggestion: Suggestion) => string | undefined
+        }
+    };
+    
+    interface JQuery {
+        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, e?: Error) => void;
+    var logInfo: (message: string) => void;    
+    var glob: CustomGlobals;
+    var require: RequireMethod;
+    var __non_webpack_require__: RequireMethod | undefined;
+
+    // Libraries
+    // 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
+        });
+    };
+    var dayjs: {};
+    var Split: (selectors: string[], config: {
+        sizes: [ number, number ];
+        gutterSize: number;
+        onDragEnd: (sizes: [ number, number ]) => void;
+    }) => {
+        destroy();
+    };
+    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);
+    }
+    interface MermaidLoader {
+
+    }
+    var mermaid: {        
+        mermaidAPI: MermaidApi;
+        registerLayoutLoaders(loader: MermaidLoader);
+        parse(content: string, opts: {
+            suppressErrors: true
+        }): {
+            config: {
+                layout: string;
+            }            
+        }
+    };
+    var MERMAID_ELK: MermaidLoader;
 }
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..6d4f587f5 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;
+    protected $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);
     }
 
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 61%
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..d1cf333b5 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,27 @@ 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 {
+    script: string;
+    relationName: string;
+    targetNoteId: string;
+    targetParentNoteId: string;
+    oldRelationName?: string;
+    newRelationName?: string;
+    newTitle?: string;
+    labelName?: string;
+    labelValue?: string;
+    oldLabelName?: string;
+    newLabelName?: string;
+}
+
+export default abstract class AbstractBulkAction {
+    attribute: FAttribute;
+    actionDef: ActionDefinition;
+
+    constructor(attribute: FAttribute, actionDef: ActionDefinition) {
         this.attribute = attribute;
         this.actionDef = actionDef;
     }
@@ -20,18 +38,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;
+    static get actionName() { return ""; }
 
-    async saveAction(data) {
-        const actionObject = Object.assign({ name: this.constructor.actionName }, data);
+    async saveAction(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
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
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
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
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
diff --git a/src/public/app/widgets/dialogs/confirm.js b/src/public/app/widgets/dialogs/confirm.ts
similarity index 75%
rename from src/public/app/widgets/dialogs/confirm.js
rename to src/public/app/widgets/dialogs/confirm.ts
index 041342c51..9ccda11ee 100644
--- a/src/public/app/widgets/dialogs/confirm.js
+++ b/src/public/app/widgets/dialogs/confirm.ts
@@ -27,7 +27,36 @@ const TPL = `
     
`; +type ConfirmDialogCallback = (val: false | ConfirmDialogOptions) => void; + +export interface ConfirmDialogOptions { + confirmed: boolean; + isDeleteNoteChecked: boolean +} + +// For "showConfirmDialog" + +export interface ConfirmWithMessageOptions { + message: string | HTMLElement | JQuery; + callback: ConfirmDialogCallback; +} + +export 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 +66,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 +91,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 +108,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 +138,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; 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..c4aa28a75 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[]; +} + +export 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; diff --git a/src/public/app/widgets/dialogs/note_type_chooser.js b/src/public/app/widgets/dialogs/note_type_chooser.ts similarity index 74% rename from src/public/app/widgets/dialogs/note_type_chooser.js rename to src/public/app/widgets/dialogs/note_type_chooser.ts index 93fe69222..57995e315 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 = `
`; +export interface ChooseNoteTypeResponse { + success: boolean; + noteType?: string; + templateNoteId?: string; +} + +type Callback = (data: ChooseNoteTypeResponse) => 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($('