refactor: add typesafety to TaskContext

This commit is contained in:
Elian Doran 2025-09-13 13:44:23 +03:00
parent 777d5ab3b7
commit 9c8b0611ea
No known key found for this signature in database
21 changed files with 111 additions and 75 deletions

View File

@ -111,7 +111,7 @@ ws.subscribeToMessages(async (message) => {
return;
}
const isProtecting = message.data.protect;
const isProtecting = message.data?.protect;
const title = isProtecting ? t("protected_session.protecting-title") : t("protected_session.unprotecting-title");
if (message.type === "taskError") {

View File

@ -137,13 +137,13 @@ class BBranch extends AbstractBeccaEntity<BBranch> {
*
* @returns true if note has been deleted, false otherwise
*/
deleteBranch(deleteId?: string, taskContext?: TaskContext): boolean {
deleteBranch(deleteId?: string, taskContext?: TaskContext<"deleteNotes">): boolean {
if (!deleteId) {
deleteId = utils.randomString(10);
}
if (!taskContext) {
taskContext = new TaskContext("no-progress-reporting");
taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
}
taskContext.increaseProgressCount();

View File

@ -1512,7 +1512,7 @@ class BNote extends AbstractBeccaEntity<BNote> {
*
* @param deleteId - optional delete identified
*/
deleteNote(deleteId: string | null = null, taskContext: TaskContext | null = null) {
deleteNote(deleteId: string | null = null, taskContext: TaskContext<"deleteNotes"> | null = null) {
if (this.isDeleted) {
return;
}
@ -1522,7 +1522,7 @@ class BNote extends AbstractBeccaEntity<BNote> {
}
if (!taskContext) {
taskContext = new TaskContext("no-progress-reporting");
taskContext = new TaskContext("no-progress-reporting", "deleteNotes", null);
}
// needs to be run before branches and attributes are deleted and thus attached relations disappear

View File

@ -108,7 +108,7 @@ function register(router: Router) {
return res.sendStatus(204);
}
note.deleteNote(null, new TaskContext("no-progress-reporting"));
note.deleteNote(null, new TaskContext("no-progress-reporting", "deleteNotes", null));
res.sendStatus(204);
});
@ -153,7 +153,7 @@ function register(router: Router) {
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`);
}
const taskContext = new TaskContext("no-progress-reporting");
const taskContext = new TaskContext("no-progress-reporting", "export", null);
// technically a branch is being exported (includes prefix), but it's such a minor difference yet usability pain
// (e.g. branchIds are not seen in UI), that we export "note export" instead.
@ -164,7 +164,7 @@ function register(router: Router) {
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const taskContext = new TaskContext("no-progress-reporting");
const taskContext = new TaskContext("no-progress-reporting", "importNotes", null);
zipImportService.importZip(taskContext, req.body, note).then((importedNote) => {
res.status(201).json({

View File

@ -236,7 +236,7 @@ function deleteBranch(req: Request) {
const eraseNotes = req.query.eraseNotes === "true";
const branch = becca.getBranchOrThrow(req.params.branchId);
const taskContext = TaskContext.getInstance(req.query.taskId as string, "deleteNotes");
const taskContext = TaskContext.getInstance(req.query.taskId as string, "deleteNotes", null);
const deleteId = utils.randomString(10);
let noteDeleted;
@ -251,7 +251,7 @@ function deleteBranch(req: Request) {
}
if (last) {
taskContext.taskSucceeded();
taskContext.taskSucceeded(null);
}
return {

View File

@ -23,7 +23,7 @@ function exportBranch(req: Request, res: Response) {
return;
}
const taskContext = new TaskContext(taskId, "export");
const taskContext = new TaskContext(taskId, "export", null);
try {
if (type === "subtree" && (format === "html" || format === "markdown")) {

View File

@ -116,7 +116,7 @@ function importAttachmentsToNote(req: Request) {
}
const parentNote = becca.getNoteOrThrow(parentNoteId);
const taskContext = TaskContext.getInstance(taskId, "importAttachment", options);
const taskContext = TaskContext.getInstance(taskId, "importNotes", options);
// unlike in note import, we let the events run, because a huge number of attachments is not likely

View File

@ -184,7 +184,7 @@ function deleteNote(req: Request) {
if (typeof taskId !== "string") {
throw new ValidationError("Missing or incorrect type for task ID.");
}
const taskContext = TaskContext.getInstance(taskId, "deleteNotes");
const taskContext = TaskContext.getInstance(taskId, "deleteNotes", null);
note.deleteNote(deleteId, taskContext);
@ -193,16 +193,16 @@ function deleteNote(req: Request) {
}
if (last) {
taskContext.taskSucceeded();
taskContext.taskSucceeded(null);
}
}
function undeleteNote(req: Request) {
const taskContext = TaskContext.getInstance(utils.randomString(10), "undeleteNotes");
const taskContext = TaskContext.getInstance(utils.randomString(10), "undeleteNotes", null);
noteService.undeleteNote(req.params.noteId, taskContext);
taskContext.taskSucceeded();
taskContext.taskSucceeded(null);
}
function sortChildNotes(req: Request) {
@ -226,7 +226,7 @@ function protectNote(req: Request) {
noteService.protectNoteRecursively(note, protect, includingSubTree, taskContext);
taskContext.taskSucceeded();
taskContext.taskSucceeded(null);
}
function setNoteTypeMime(req: Request) {

View File

@ -6,7 +6,7 @@ import type TaskContext from "../task_context.js";
import type BBranch from "../../becca/entities/bbranch.js";
import type { Response } from "express";
function exportToOpml(taskContext: TaskContext, branch: BBranch, version: string, res: Response) {
function exportToOpml(taskContext: TaskContext<"export">, branch: BBranch, version: string, res: Response) {
if (!["1.0", "2.0"].includes(version)) {
throw new Error(`Unrecognized OPML version ${version}`);
}
@ -77,7 +77,7 @@ function exportToOpml(taskContext: TaskContext, branch: BBranch, version: string
</opml>`);
res.end();
taskContext.taskSucceeded();
taskContext.taskSucceeded(null);
}
function prepareText(text: string) {

View File

@ -10,7 +10,7 @@ import type BBranch from "../../becca/entities/bbranch.js";
import type { Response } from "express";
import type BNote from "../../becca/entities/bnote.js";
function exportSingleNote(taskContext: TaskContext, branch: BBranch, format: "html" | "markdown", res: Response) {
function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) {
const note = branch.getNote();
if (note.type === "image" || note.type === "file") {
@ -30,7 +30,7 @@ function exportSingleNote(taskContext: TaskContext, branch: BBranch, format: "ht
res.send(payload);
taskContext.increaseProgressCount();
taskContext.taskSucceeded();
taskContext.taskSucceeded(null);
}
export function mapByNoteType(note: BNote, content: string | Buffer<ArrayBufferLike>, format: "html" | "markdown") {

View File

@ -40,7 +40,7 @@ export interface AdvancedExportOptions {
customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn;
}
async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) {
if (!["html", "markdown"].includes(format)) {
throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`);
}
@ -611,7 +611,7 @@ ${markdownContent}`;
archive.pipe(res);
await archive.finalize();
taskContext.taskSucceeded();
taskContext.taskSucceeded(null);
} catch (e: unknown) {
const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`;
log.error(message);
@ -627,7 +627,7 @@ ${markdownContent}`;
async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
const fileOutputStream = fs.createWriteStream(zipFilePath);
const taskContext = new TaskContext("no-progress-reporting");
const taskContext = new TaskContext("no-progress-reporting", "export", null);
const note = becca.getNote(noteId);

View File

@ -55,7 +55,7 @@ interface Note {
let note: Partial<Note> = {};
let resource: Resource;
function importEnex(taskContext: TaskContext, file: File, parentNote: BNote): Promise<BNote> {
function importEnex(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote): Promise<BNote> {
const saxStream = sax.createStream(true);
const rootNoteTitle = file.originalname.toLowerCase().endsWith(".enex") ? file.originalname.substr(0, file.originalname.length - 5) : file.originalname;

View File

@ -91,14 +91,14 @@ function getMime(fileName: string) {
return mimeFromExt || mimeTypes.lookup(fileNameLc);
}
function getType(options: TaskData, mime: string): NoteType {
function getType(options: TaskData<"importNotes">, mime: string): NoteType {
const mimeLc = mime?.toLowerCase();
switch (true) {
case options.textImportedAsText && ["text/html", "text/markdown", "text/x-markdown", "text/mdx"].includes(mimeLc):
case options?.textImportedAsText && ["text/html", "text/markdown", "text/x-markdown", "text/mdx"].includes(mimeLc):
return "text";
case options.codeImportedAsCode && CODE_MIME_TYPES.has(mimeLc):
case options?.codeImportedAsCode && CODE_MIME_TYPES.has(mimeLc):
return "code";
case mime.startsWith("image/"):

View File

@ -28,7 +28,7 @@ interface OpmlOutline {
outline: OpmlOutline[];
}
async function importOpml(taskContext: TaskContext, fileBuffer: string | Buffer, parentNote: BNote) {
async function importOpml(taskContext: TaskContext<"importNotes">, fileBuffer: string | Buffer, parentNote: BNote) {
const xml = await new Promise<OpmlXml>(function (resolve, reject) {
parseString(fileBuffer, function (err: any, result: OpmlXml) {
if (err) {

View File

@ -14,7 +14,7 @@ import htmlSanitizer from "../html_sanitizer.js";
import type { File } from "./common.js";
import type { NoteType } from "@triliumnext/commons";
function importSingleFile(taskContext: TaskContext, file: File, parentNote: BNote) {
function importSingleFile(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const mime = mimeService.getMime(file.originalname) || file.mimetype;
if (taskContext?.data?.textImportedAsText) {
@ -42,7 +42,7 @@ function importSingleFile(taskContext: TaskContext, file: File, parentNote: BNot
return importFile(taskContext, file, parentNote);
}
function importImage(file: File, parentNote: BNote, taskContext: TaskContext) {
function importImage(file: File, parentNote: BNote, taskContext: TaskContext<"importNotes">) {
if (typeof file.buffer === "string") {
throw new Error("Invalid file content for image.");
}
@ -53,7 +53,7 @@ function importImage(file: File, parentNote: BNote, taskContext: TaskContext) {
return note;
}
function importFile(taskContext: TaskContext, file: File, parentNote: BNote) {
function importFile(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const originalName = file.originalname;
const { note } = noteService.createNewNote({
@ -72,7 +72,7 @@ function importFile(taskContext: TaskContext, file: File, parentNote: BNote) {
return note;
}
function importCodeNote(taskContext: TaskContext, file: File, parentNote: BNote) {
function importCodeNote(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const content = processStringOrBuffer(file.buffer);
const detectedMime = mimeService.getMime(file.originalname) || file.mimetype;
@ -97,7 +97,7 @@ function importCodeNote(taskContext: TaskContext, file: File, parentNote: BNote)
return note;
}
function importCustomType(taskContext: TaskContext, file: File, parentNote: BNote, type: NoteType, mime: string) {
function importCustomType(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote, type: NoteType, mime: string) {
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const content = processStringOrBuffer(file.buffer);
@ -115,7 +115,7 @@ function importCustomType(taskContext: TaskContext, file: File, parentNote: BNot
return note;
}
function importPlainText(taskContext: TaskContext, file: File, parentNote: BNote) {
function importPlainText(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const plainTextContent = processStringOrBuffer(file.buffer);
const htmlContent = convertTextToHtml(plainTextContent);
@ -150,7 +150,7 @@ function convertTextToHtml(text: string) {
return text;
}
function importMarkdown(taskContext: TaskContext, file: File, parentNote: BNote) {
function importMarkdown(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const markdownContent = processStringOrBuffer(file.buffer);
@ -174,7 +174,7 @@ function importMarkdown(taskContext: TaskContext, file: File, parentNote: BNote)
return note;
}
function importHtml(taskContext: TaskContext, file: File, parentNote: BNote) {
function importHtml(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
let content = processStringOrBuffer(file.buffer);
// Try to get title from HTML first, fall back to filename
@ -202,7 +202,7 @@ function importHtml(taskContext: TaskContext, file: File, parentNote: BNote) {
return note;
}
function importAttachment(taskContext: TaskContext, file: File, parentNote: BNote) {
function importAttachment(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const mime = mimeService.getMime(file.originalname) || file.mimetype;
if (mime.startsWith("image/") && typeof file.buffer !== "string") {

View File

@ -30,7 +30,7 @@ interface ImportZipOpts {
preserveIds?: boolean;
}
async function importZip(taskContext: TaskContext, fileBuffer: Buffer, importRootNote: BNote, opts?: ImportZipOpts): Promise<BNote> {
async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Buffer, importRootNote: BNote, opts?: ImportZipOpts): Promise<BNote> {
/** maps from original noteId (in ZIP file) to newly generated noteId */
const noteIdMap: Record<string, string> = {};
/** type maps from original attachmentId (in ZIP file) to newly generated attachmentId */
@ -174,7 +174,7 @@ async function importZip(taskContext: TaskContext, fileBuffer: Buffer, importRoo
return noteId;
}
function detectFileTypeAndMime(taskContext: TaskContext, filePath: string) {
function detectFileTypeAndMime(taskContext: TaskContext<"importNotes">, filePath: string) {
const mime = mimeService.getMime(filePath) || "application/octet-stream";
const type = mimeService.getType(taskContext.data || {}, mime);

View File

@ -296,7 +296,7 @@ function createNewNoteWithTarget(target: "into" | "after" | "before", targetBran
}
}
function protectNoteRecursively(note: BNote, protect: boolean, includingSubTree: boolean, taskContext: TaskContext) {
function protectNoteRecursively(note: BNote, protect: boolean, includingSubTree: boolean, taskContext: TaskContext<"protectNotes">) {
protectNote(note, protect);
taskContext.increaseProgressCount();
@ -765,7 +765,7 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment
}
}
function undeleteNote(noteId: string, taskContext: TaskContext) {
function undeleteNote(noteId: string, taskContext: TaskContext<"undeleteNotes">) {
const noteRow = sql.getRow<NoteRow>("SELECT * FROM notes WHERE noteId = ?", [noteId]);
if (!noteRow.isDeleted || !noteRow.deleteId) {
@ -785,7 +785,7 @@ function undeleteNote(noteId: string, taskContext: TaskContext) {
}
}
function undeleteBranch(branchId: string, deleteId: string, taskContext: TaskContext) {
function undeleteBranch(branchId: string, deleteId: string, taskContext: TaskContext<"undeleteNotes">) {
const branchRow = sql.getRow<BranchRow>("SELECT * FROM branches WHERE branchId = ?", [branchId]);
if (!branchRow.isDeleted) {

View File

@ -122,7 +122,7 @@ async function createInitialDatabase(skipDemoDb?: boolean) {
log.info("Importing demo content ...");
const dummyTaskContext = new TaskContext("no-progress-reporting", "import", false);
const dummyTaskContext = new TaskContext("no-progress-reporting", "importNotes", null);
if (demoFile) {
await zipImportService.importZip(dummyTaskContext, demoFile, rootNote);

View File

@ -1,20 +1,20 @@
"use strict";
import type { TaskType } from "@triliumnext/commons";
import type { TaskData, TaskResult, TaskType, WebSocketMessage } from "@triliumnext/commons";
import ws from "./ws.js";
// taskId => TaskContext
const taskContexts: Record<string, TaskContext<TaskType>> = {};
const taskContexts: Record<string, TaskContext<any>> = {};
class TaskContext<TaskTypeT extends TaskType> {
class TaskContext<T extends TaskType> {
private taskId: string;
private taskType: TaskType;
private progressCount: number;
private lastSentCountTs: number;
data: TaskData | null;
data: TaskData<T>;
noteDeletionHandlerTriggered: boolean;
constructor(taskId: string, taskType: TaskTypeT, data: {} | null = {}) {
constructor(taskId: string, taskType: T, data: TaskData<T>) {
this.taskId = taskId;
this.taskType = taskType;
this.data = data;
@ -31,7 +31,7 @@ class TaskContext<TaskTypeT extends TaskType> {
this.increaseProgressCount();
}
static getInstance<TaskTypeT extends TaskType>(taskId: string, taskType: TaskTypeT, data: {} | null = null): TaskContext<TaskTypeT> {
static getInstance<T extends TaskType>(taskId: string, taskType: T, data: TaskData<T>): TaskContext<T> {
if (!taskContexts[taskId]) {
taskContexts[taskId] = new TaskContext(taskId, taskType, data);
}
@ -51,7 +51,7 @@ class TaskContext<TaskTypeT extends TaskType> {
taskType: this.taskType,
data: this.data,
progressCount: this.progressCount
});
} as WebSocketMessage);
}
}
@ -62,17 +62,17 @@ class TaskContext<TaskTypeT extends TaskType> {
taskType: this.taskType,
data: this.data,
message
});
} as WebSocketMessage);
}
taskSucceeded(result?: string | Record<string, string | undefined>) {
taskSucceeded(result: TaskResult<T>) {
ws.sendMessageToAllClients({
type: "taskSucceeded",
taskId: this.taskId,
taskType: this.taskType,
data: this.data,
result
});
} as WebSocketMessage);
}
}

View File

@ -13,7 +13,7 @@ import type { IncomingMessage, Server as HttpServer } from "http";
import { WebSocketMessage, type EntityChange } from "@triliumnext/commons";
let webSocketServer!: WebSocketServer;
let lastSyncedPush: number | null = null;
let lastSyncedPush: number;
type SessionParser = (req: IncomingMessage, params: {}, cb: () => void) => void;
function init(httpServer: HttpServer, sessionParser: SessionParser) {

View File

@ -26,37 +26,64 @@ export interface EntityChangeRecord {
entity?: EntityRow;
}
type TaskStatus<TypeT, DataT, ResultT> = {
type TaskDataDefinitions = {
empty: null,
deleteNotes: null,
undeleteNotes: null,
export: null,
protectNotes: {
protect: boolean;
}
importNotes: {
textImportedAsText?: boolean;
codeImportedAsCode?: boolean;
replaceUnderscoresWithSpaces?: boolean;
shrinkImages?: boolean;
safeImport?: boolean;
} | null,
importAttachments: null
}
type TaskResultDefinitions = {
empty: null,
deleteNotes: null,
undeleteNotes: null,
export: null,
protectNotes: null,
importNotes: {
parentNoteId?: string;
importedNoteId?: string
};
importAttachments: {
parentNoteId?: string;
importedNoteId?: string
};
}
export type TaskType = keyof TaskDataDefinitions | keyof TaskResultDefinitions;
export type TaskData<T extends TaskType> = TaskDataDefinitions[T];
export type TaskResult<T extends TaskType> = TaskResultDefinitions[T];
type TaskDefinition<T extends TaskType> = {
type: "taskProgressCount",
taskId: string;
taskType: TypeT;
data: DataT,
taskType: T;
data: TaskData<T>,
progressCount: number
} | {
type: "taskError",
taskId: string;
taskType: TypeT;
data: DataT;
taskType: T;
data: TaskData<T>,
message: string;
} | {
type: "taskSucceeded",
taskId: string;
taskType: TypeT;
data: DataT;
result: ResultT;
taskType: T;
data: TaskData<T>,
result: TaskResult<T>;
}
type TaskDefinitions =
TaskStatus<"protectNotes", { protect: boolean; }, null>
| TaskStatus<"importNotes", null, { importedNoteId: string }>
| TaskStatus<"importAttachments", null, { parentNoteId?: string; importedNoteId: string }>
| TaskStatus<"deleteNotes", null, null>
| TaskStatus<"undeleteNotes", null, null>
| TaskStatus<"export", null, null>
;
export type TaskType = TaskDefinitions["taskType"];
export interface OpenedFileUpdateStatus {
entityType: string;
entityId: string;
@ -64,7 +91,16 @@ export interface OpenedFileUpdateStatus {
filePath: string;
}
export type WebSocketMessage = TaskDefinitions | {
type AllTaskDefinitions =
| TaskDefinition<"empty">
| TaskDefinition<"deleteNotes">
| TaskDefinition<"undeleteNotes">
| TaskDefinition<"export">
| TaskDefinition<"protectNotes">
| TaskDefinition<"importNotes">
| TaskDefinition<"importAttachments">;
export type WebSocketMessage = AllTaskDefinitions | {
type: "ping"
} | {
type: "frontend-update",