Merge pull request #821 from TriliumNext/feature/client_typescript_port2

Port frontend to TypeScript (36.7% -> 48.5%)
This commit is contained in:
Elian Doran 2024-12-22 15:23:01 +02:00 committed by GitHub
commit 2ec903893c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 1475 additions and 822 deletions

View File

@ -10,4 +10,4 @@ echo By file
cloc HEAD \
--git --md \
--include-lang=javascript,typescript \
--by-file
--by-file | grep \.js\|

View File

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

View File

@ -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<CommandData>;
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<NoteDetailWidget>) => 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<K extends EventNames | CommandNames>(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<K extends CommandNames>(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) {

View File

@ -16,6 +16,7 @@ export default class Component {
children: Component[];
initialized: Promise<void> | null;
parent?: Component;
position!: number;
constructor() {
this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`;

View File

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

View File

@ -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<string, number>;
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;
}

View File

@ -1,11 +1,19 @@
import { AttributeType } from "../entities/fattribute.js";
import server from "./server.js";
interface InitOptions {
$el: JQuery<HTMLElement>;
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<string[]>(`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<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`))
.map(attribute => ({ value: attribute }));
if (attributeValues.length === 0) {

View File

@ -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[]

View File

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

View File

@ -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 = $("<span>");
@ -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 = $('<span class="rendered-note-attributes">');
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();

View File

@ -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)) {

View File

@ -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<Response>(`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<Response>(`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<Response>(`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<ResolveOptions>(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<Response>(`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<Response>(`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<Response>(`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<Response>(`notes/${noteId}/clone-after/${afterBranchId}`);
if (!resp.success) {
toastService.showError(resp.message);

View File

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

View File

@ -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<Bundle>(`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<HTMLElement>) {
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<Bundle[]>("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<string, Widget[]>;
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<Bundle[]>("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
})
});

View File

@ -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) {

View File

@ -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 = $('<div>');
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<HTMLElement>) {
// entity must be FNote
const blob = await note.getBlob();
if (!utils.isHtmlEmpty(blob.content)) {
if (blob && !utils.isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').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<HTMLElement>) {
const blob = await note.getBlob();
const $codeBlock = $("<code>");
$codeBlock.text(blob.content);
$codeBlock.text(blob?.content || "");
$renderedContent.append($("<pre>").append($codeBlock));
await applySingleBlockSyntaxHighlight($codeBlock, mime_types.normalizeMimeTypeForCKEditor(note.mime));
}
function renderImage(entity, $renderedContent, options = {}) {
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
const encodedTitle = encodeURIComponent(entity.title);
let url;
@ -146,7 +144,7 @@ function renderImage(entity, $renderedContent, options = {}) {
.css('justify-content', 'center');
const $img = $("<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<HTMLElement>) {
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 = $('<button class="file-download btn btn-primary" type="button">Download</button>');
@ -222,11 +220,11 @@ function renderFile(entity, type, $renderedContent) {
$renderedContent.append($content);
}
async function renderMermaid(note, $renderedContent) {
async function renderMermaid(note: FNote, $renderedContent: JQuery<HTMLElement>) {
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<void>}
*/
async function renderChildrenList($renderedContent, note) {
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, 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';
}

View File

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

View File

@ -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", <ConfirmWithMessageOptions>{
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}));
}

View File

@ -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<string, Record<string, Message>> = {
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;
}

View File

@ -243,7 +243,7 @@ class FrocaImpl implements Froca {
}).filter(note => !!note) as FNote[];
}
async getNotes(noteIds: string[], silentNotFoundError = false): Promise<FNote[]> {
async getNotes(noteIds: string[] | JQuery<string>, silentNotFoundError = false): Promise<FNote[]> {
noteIds = Array.from(new Set(noteIds)); // make unique
const missingNoteIds = noteIds.filter(noteId => !this.notes[noteId]);

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { t } from "./i18n.js";
import toastService from "./toast.js";
function copyImageReferenceToClipboard($imageWrapper) {
function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
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 {

View File

@ -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<string, string | Blob>) {
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"),

View File

@ -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<string, Action> = {};
const keyboardActionsLoaded = server.get('keyboard-actions').then(actions => {
// TODO: Deduplicate with server.
interface Action {
actionName: CommandNames;
effectiveShortcuts: string[];
scope: string;
}
const keyboardActionsLoaded = server.get<Action[]>('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<HTMLElement>, 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<HTMLElement>) {
//@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) {

View File

@ -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<string>();
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<T>(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<string, JQuery.jqXHR> = {};
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<HTMLElement> | null = null;
function loadHighlightingTheme(theme: string) {
if (!theme) {
return;
}

View File

@ -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 $("<span>").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 $("<img>")
.attr("src", `api/images/${noteId}/${encodedTitle}?${Math.random()}`)
.attr("alt", linkTitle);
.attr("alt", linkTitle || "");
}
const $container = $("<span>");
@ -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<string, string | undefined>)[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<HTMLElement>) {
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<HTMLElement>, 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($("<span>").addClass(icon));
if (icon) {
$el.prepend($("<span>").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 => {

View File

@ -1,4 +1,4 @@
import { EntityChange } from "../../../services/entity_changes_interface.js";
import { EntityChange } from "../server_types.js";
interface BranchRow {
branchId: string;

View File

@ -15,7 +15,7 @@ function init() {
}
}
function exec(cmd) {
function exec(cmd: string) {
document.execCommand(cmd);
return false;

View File

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

View File

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

View File

@ -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<Suggestion[]>(`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<HTMLElement>) {
if (utils.isMobile()) {
return;
}
@ -90,7 +109,7 @@ function clearText($el) {
$el.autocomplete("val", "").trigger('change');
}
function setText($el, text) {
function setText($el: JQuery<HTMLElement>, text: string) {
if (utils.isMobile()) {
return;
}
@ -101,7 +120,7 @@ function setText($el, text) {
.autocomplete("open");
}
function showRecentNotes($el) {
function showRecentNotes($el:JQuery<HTMLElement>) {
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<HTMLElement>, 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<HTMLElement>, 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")

View File

@ -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<Response>(`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<ChooseNoteTypeResponse>(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<DuplicateResponse>(`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 {

View File

@ -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<HTMLElement>) {
$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: `<div class="tooltip note-tooltip ${tooltipClass}" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>`,
@ -114,7 +116,7 @@ async function mouseEnterHandler() {
}
}
async function renderTooltip(note) {
async function renderTooltip(note: FNote | null) {
if (!note) {
return '<div>Note has been deleted.</div>';
}
@ -126,7 +128,11 @@ async function renderTooltip(note) {
return;
}
let content = `<h5 class="note-tooltip-title">${(await treeService.getNoteTitleWithPathAsSuffix(bestNotePath)).prop('outerHTML')}</h5>`;
const noteTitleWithPathAsSuffix = await treeService.getNoteTitleWithPathAsSuffix(bestNotePath);
let content = "";
if (noteTitleWithPathAsSuffix) {
content = `<h5 class="note-tooltip-title">${noteTitleWithPathAsSuffix.prop('outerHTML')}</h5>`;
}
const {$renderedAttributes} = await attributeRenderer.renderNormalAttributes(note);

View File

@ -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<string[]>("search-templates");
const templateNotes = await froca.getNotes(templateNoteIds);
if (templateNotes.length > 0) {

View File

@ -1,7 +1,7 @@
import server from "./server.js";
type OptionValue = string | number;
type OptionValue = number | string;
class Options {
initializedPromise: Promise<void>;

View File

@ -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<any, any, any> | 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<Response>('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,

View File

@ -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<HTMLElement>) {
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<Bundle>(`script/bundle/${renderNoteId}`);
const $scriptContainer = $('<div>');
$el.append($scriptContainer);

View File

@ -1,9 +1,9 @@
import options from "./options.js";
let leftInstance;
let rightInstance;
let leftInstance: ReturnType<typeof Split> | null;
let rightInstance: ReturnType<typeof Split> | null;
function setupLeftPaneResizer(leftPaneVisible) {
function setupLeftPaneResizer(leftPaneVisible: boolean) {
if (leftInstance) {
leftInstance.destroy();
leftInstance = null;

View File

@ -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<HTMLElement> | null = null) {
const modules: Record<string, { exports: unknown }> = {};
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);

View File

@ -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<string[]>(`search/${encodeURIComponent(searchString)}`);
}
async function searchForNotes(searchString) {
async function searchForNotes(searchString: string) {
const noteIds = await searchForNoteIds(searchString);
return await froca.getNotes(noteIds);

View File

@ -1,14 +1,17 @@
import utils from "./utils.js";
function removeGlobalShortcut(namespace) {
type ElementType = HTMLElement | Document;
type Handler = (e: JQuery.TriggeredEvent<ElementType, string, ElementType, ElementType>) => 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<ElementType>, 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;
}

View File

@ -1,4 +1,4 @@
type Callback = () => Promise<void>;
type Callback = () => Promise<void> | void;
export default class SpacedUpdate {
private updater: Callback;

View File

@ -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<SyncResult>('sync/now');
if (result.success) {
toastService.showMessage(t("sync.finished-successfully"));

View File

@ -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<HTMLElement>) {
if (!isSyntaxHighlightEnabled()) {
return;
}
@ -38,11 +38,8 @@ export async function applySyntaxHighlight($container) {
/**
* Applies syntax highlight to the given code block (assumed to be <pre><code>), using highlight.js.
*
* @param {*} $codeBlock
* @param {*} normalizedMimeType
*/
export async function applySingleBlockSyntaxHighlight($codeBlock, normalizedMimeType) {
export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLElement>, 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)) {

View File

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

View File

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

View File

@ -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<T>(array: T[], fn: (arg0: T) => [key: string, value: T]) {
const obj: Record<string, T> = {};
function toObject<T, R>(array: T[], fn: (arg0: T) => [key: string, value: R]) {
const obj: Record<string, R> = {};
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<HTMLElement>, 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<HTMLElement>) {
}
}
function initHelpButtons($el: JQuery<HTMLElement>) {
function initHelpButtons($el: JQuery<HTMLElement> | JQuery<Window>) {
// 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 => {

View File

@ -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[] = [];

View File

@ -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<Record<string, string>>;
getComponentByEl: typeof appContext.getComponentByEl;
getHeaders: typeof server.getHeaders;
getReferenceLinkTitle: (href: string) => Promise<string>;
getReferenceLinkTitleSync: (href: string) => string;
getActiveContextNote: FNote;
requireLibrary: (library: string) => Promise<void>;
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<HttpBackendOptions>;
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;
}

View File

@ -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<string, string>;
private classes: string[];
private childPositionCounter: number;
private cssEl?: string;
protected $widget!: JQuery<HTMLElement>;
_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);
}

View File

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

View File

@ -27,7 +27,36 @@ const TPL = `
</div>
</div>`;
type ConfirmDialogCallback = (val: false | ConfirmDialogOptions) => void;
export interface ConfirmDialogOptions {
confirmed: boolean;
isDeleteNoteChecked: boolean
}
// For "showConfirmDialog"
export interface ConfirmWithMessageOptions {
message: string | HTMLElement | JQuery<HTMLElement>;
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<HTMLElement> | null;
private $confirmContent!: JQuery<HTMLElement>;
private $okButton!: JQuery<HTMLElement>;
private $cancelButton!: JQuery<HTMLElement>;
private $custom!: JQuery<HTMLElement>;
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;

View File

@ -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 = `
<div class="delete-notes-dialog modal mx-auto" tabindex="-1" role="dialog">
@ -54,11 +73,29 @@ const TPL = `
</div>`;
export default class DeleteNotesDialog extends BasicWidget {
private branchIds: string[] | null;
private resolve!: (options: ResolveOptions) => void;
private $content!: JQuery<HTMLElement>;
private $okButton!: JQuery<HTMLElement>;
private $cancelButton!: JQuery<HTMLElement>;
private $deleteNotesList!: JQuery<HTMLElement>;
private $brokenRelationsList!: JQuery<HTMLElement>;
private $deletedNotesCount!: JQuery<HTMLElement>;
private $noNoteToDeleteWrapper!: JQuery<HTMLElement>;
private $deleteNotesListWrapper!: JQuery<HTMLElement>;
private $brokenRelationsListWrapper!: JQuery<HTMLElement>;
private $brokenRelationsCount!: JQuery<HTMLElement>;
private $deleteAllClones!: JQuery<HTMLElement>;
private $eraseNotes!: JQuery<HTMLElement>;
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<Response>('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;

View File

@ -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 = `
</div>
</div>`;
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<HTMLElement>;
private $noteTypeDropdown!: JQuery<HTMLElement>;
private $originalFocused: JQuery<HTMLElement> | null;
private $originalDialog: JQuery<HTMLElement> | 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($('<h6 class="dropdown-header">').append(t("note_type_chooser.templates")));
}
else {
} else {
this.$noteTypeDropdown.append(
$('<a class="dropdown-item" tabindex="0">')
.attr("data-note-type", noteType.type)
.attr("data-template-note-id", noteType.templateNoteId)
.append($("<span>").addClass(noteType.uiIcon))
.attr("data-note-type", (noteType as NoteType).type)
.attr("data-template-note-id", (noteType as NoteType).templateNoteId || "")
.append($("<span>").addClass((noteType as NoteType).uiIcon))
.append(` ${noteType.title}`)
);
}
@ -127,16 +148,18 @@ export default class NoteTypeChooserDialog extends BasicWidget {
this.resolve = callback;
}
doResolve(e) {
doResolve(e: JQuery.KeyDownEvent | JQuery.ClickEvent) {
const $item = $(e.target).closest(".dropdown-item");
const noteType = $item.attr("data-note-type");
const templateNoteId = $item.attr("data-template-note-id");
this.resolve({
success: true,
noteType,
templateNoteId
});
if (this.resolve) {
this.resolve({
success: true,
noteType,
templateNoteId
});
}
this.resolve = null;
this.modal.hide();

View File

@ -20,7 +20,34 @@ const TPL = `
</div>
</div>`;
interface ShownCallbackData {
$dialog: JQuery<HTMLElement>;
$question: JQuery<HTMLElement> | null;
$answer: JQuery<HTMLElement> | null;
$form: JQuery<HTMLElement>;
}
export interface PromptDialogOptions {
title?: string;
message?: string;
defaultValue?: string;
shown: PromptShownDialogCallback;
callback: (value: unknown) => void;
}
export type PromptShownDialogCallback = ((callback: ShownCallbackData) => void) | null;
export default class PromptDialog extends BasicWidget {
private resolve: ((val: string | null) => void) | null;
private shownCb: PromptShownDialogCallback;
private modal!: bootstrap.Modal;
private $dialogBody!: JQuery<HTMLElement>;
private $question!: JQuery<HTMLElement> | null;
private $answer!: JQuery<HTMLElement> | null;
private $form!: JQuery<HTMLElement>;
constructor() {
super();
@ -30,6 +57,8 @@ export default class PromptDialog extends BasicWidget {
doRender() {
this.$widget = $(TPL);
// TODO: Fix once we use proper ES imports.
//@ts-ignore
this.modal = bootstrap.Modal.getOrCreateInstance(this.$widget);
this.$dialogBody = this.$widget.find(".modal-body");
this.$form = this.$widget.find(".prompt-dialog-form");
@ -46,7 +75,7 @@ export default class PromptDialog extends BasicWidget {
});
}
this.$answer.trigger('focus').select();
this.$answer?.trigger('focus').select();
});
this.$widget.on("hidden.bs.modal", () => {
@ -57,13 +86,15 @@ export default class PromptDialog extends BasicWidget {
this.$form.on('submit', e => {
e.preventDefault();
this.resolve(this.$answer.val());
if (this.resolve) {
this.resolve(this.$answer?.val() as string);
}
this.modal.hide();
});
}
showPromptDialogEvent({ title, message, defaultValue, shown, callback }) {
showPromptDialogEvent({ title, message, defaultValue, shown, callback }: PromptDialogOptions) {
this.shownCb = shown;
this.resolve = callback;
@ -71,7 +102,7 @@ export default class PromptDialog extends BasicWidget {
this.$question = $("<label>")
.prop("for", "prompt-dialog-answer")
.text(message);
.text(message || "");
this.$answer = $("<input>")
.prop("type", "text")

View File

@ -7,6 +7,7 @@
"strict": true,
"noImplicitAny": true,
"resolveJsonModule": true,
"allowJs": true,
"lib": [
"ES2022"
],
@ -18,14 +19,14 @@
"./src/**/*.js",
"./src/**/*.ts",
"./*.ts",
"./spec/**/*.ts",
"./spec-es6/**/*.ts"
"./spec/**/*.ts"
],
"exclude": [
"./src/public/**/*",
"./node_modules/**/*"
"./node_modules/**/*",
"./spec-es6/**/*.ts"
],
"files": [
"src/types.d.ts"
"src/types.d.ts",
"src/public/app/types.d.ts"
]
}