Notes/src/etapi/notes.ts

268 lines
9.6 KiB
TypeScript
Raw Normal View History

import becca from "../becca/becca.js";
import utils from "../services/utils.js";
import eu from "./etapi_utils.js";
import mappers from "./mappers.js";
import noteService from "../services/notes.js";
import TaskContext from "../services/task_context.js";
import v from "./validators.js";
import searchService from "../services/search/services/search.js";
import SearchContext from "../services/search/search_context.js";
import zipExportService from "../services/export/zip.js";
import zipImportService from "../services/import/zip.js";
2025-01-09 18:07:02 +02:00
import { Request, Router } from "express";
import { ParsedQs } from "qs";
import { NoteParams } from "../services/note-interface.js";
import { SearchParams } from "../services/search/services/types.js";
import { ValidatorMap } from "./etapi-interface.js";
2024-04-07 16:56:45 +03:00
function register(router: Router) {
2025-01-09 18:07:02 +02:00
eu.route(router, "get", "/etapi/notes", (req, res, next) => {
2024-04-07 14:56:22 +03:00
const { search } = req.query;
2022-01-08 13:18:12 +01:00
2024-04-07 16:56:45 +03:00
if (typeof search !== "string" || !search?.trim()) {
2025-01-09 18:07:02 +02:00
throw new eu.EtapiError(400, "SEARCH_QUERY_PARAM_MANDATORY", "'search' query parameter is mandatory.");
2022-01-08 13:18:12 +01:00
}
2022-01-08 13:18:12 +01:00
const searchParams = parseSearchParams(req);
2022-01-10 17:09:20 +01:00
const searchContext = new SearchContext(searchParams);
2022-01-10 17:09:20 +01:00
const searchResults = searchService.findResultsWithQuery(search, searchContext);
2025-01-09 18:07:02 +02:00
const foundNotes = searchResults.map((sr) => becca.notes[sr.noteId]);
2024-04-07 16:56:45 +03:00
const resp: any = {
2025-01-09 18:07:02 +02:00
results: foundNotes.map((note) => mappers.mapNoteToPojo(note))
2022-01-10 17:09:20 +01:00
};
2022-01-10 17:09:20 +01:00
if (searchContext.debugInfo) {
resp.debugInfo = searchContext.debugInfo;
}
2022-01-10 17:09:20 +01:00
res.json(resp);
2022-01-08 13:18:12 +01:00
});
2025-01-09 18:07:02 +02:00
eu.route(router, "get", "/etapi/notes/:noteId", (req, res, next) => {
2022-01-10 17:09:20 +01:00
const note = eu.getAndCheckNote(req.params.noteId);
2022-01-07 19:33:59 +01:00
res.json(mappers.mapNoteToPojo(note));
});
2024-04-07 16:56:45 +03:00
const ALLOWED_PROPERTIES_FOR_CREATE_NOTE: ValidatorMap = {
2025-01-09 18:07:02 +02:00
parentNoteId: [v.mandatory, v.notNull, v.isNoteId],
title: [v.mandatory, v.notNull, v.isString],
type: [v.mandatory, v.notNull, v.isNoteType],
mime: [v.notNull, v.isString],
content: [v.notNull, v.isString],
notePosition: [v.notNull, v.isInteger],
prefix: [v.notNull, v.isString],
isExpanded: [v.notNull, v.isBoolean],
noteId: [v.notNull, v.isValidEntityId],
dateCreated: [v.notNull, v.isString, v.isLocalDateTime],
utcDateCreated: [v.notNull, v.isString, v.isUtcDateTime]
2022-01-12 19:32:23 +01:00
};
2025-01-09 18:07:02 +02:00
eu.route(router, "post", "/etapi/create-note", (req, res, next) => {
2024-04-07 16:56:45 +03:00
const _params = {};
eu.validateAndPatch(_params, req.body, ALLOWED_PROPERTIES_FOR_CREATE_NOTE);
const params = _params as NoteParams;
2022-01-07 19:33:59 +01:00
try {
const resp = noteService.createNewNote(params);
res.status(201).json({
2022-01-07 19:33:59 +01:00
note: mappers.mapNoteToPojo(resp.note),
branch: mappers.mapBranchToPojo(resp.branch)
});
2025-01-09 18:07:02 +02:00
} catch (e: any) {
2022-01-12 19:32:23 +01:00
return eu.sendError(res, 500, eu.GENERIC_CODE, e.message);
2022-01-07 19:33:59 +01:00
}
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
2025-01-09 18:07:02 +02:00
title: [v.notNull, v.isString],
type: [v.notNull, v.isString],
mime: [v.notNull, v.isString],
dateCreated: [v.notNull, v.isString, v.isLocalDateTime],
utcDateCreated: [v.notNull, v.isString, v.isUtcDateTime]
2022-01-07 19:33:59 +01:00
};
2025-01-09 18:07:02 +02:00
eu.route(router, "patch", "/etapi/notes/:noteId", (req, res, next) => {
2023-06-05 09:23:42 +02:00
const note = eu.getAndCheckNote(req.params.noteId);
2022-01-07 23:06:04 +01:00
2022-01-07 19:33:59 +01:00
if (note.isProtected) {
2023-06-05 09:23:42 +02:00
throw new eu.EtapiError(400, "NOTE_IS_PROTECTED", `Note '${req.params.noteId}' is protected and cannot be modified through ETAPI.`);
2022-01-07 19:33:59 +01:00
}
2022-01-07 23:06:04 +01:00
2022-01-10 17:09:20 +01:00
eu.validateAndPatch(note, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
2022-01-12 19:32:23 +01:00
note.save();
2022-01-07 23:06:04 +01:00
2022-01-07 19:33:59 +01:00
res.json(mappers.mapNoteToPojo(note));
});
2025-01-09 18:07:02 +02:00
eu.route(router, "delete", "/etapi/notes/:noteId", (req, res, next) => {
2024-04-07 14:56:22 +03:00
const { noteId } = req.params;
2022-01-07 19:33:59 +01:00
const note = becca.getNote(noteId);
2023-06-05 09:23:42 +02:00
if (!note) {
2022-01-07 19:33:59 +01:00
return res.sendStatus(204);
}
2025-01-09 18:07:02 +02:00
note.deleteNote(null, new TaskContext("no-progress-reporting"));
2022-01-07 19:33:59 +01:00
res.sendStatus(204);
});
2022-01-08 12:01:54 +01:00
2025-01-09 18:07:02 +02:00
eu.route(router, "get", "/etapi/notes/:noteId/content", (req, res, next) => {
2022-01-10 17:09:20 +01:00
const note = eu.getAndCheckNote(req.params.noteId);
2022-01-08 12:01:54 +01:00
2023-06-05 09:23:42 +02:00
if (note.isProtected) {
throw new eu.EtapiError(400, "NOTE_IS_PROTECTED", `Note '${req.params.noteId}' is protected and content cannot be read through ETAPI.`);
}
2022-01-08 12:01:54 +01:00
const filename = utils.formatDownloadTitle(note.title, note.type, note.mime);
2025-01-09 18:07:02 +02:00
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
2022-01-08 12:01:54 +01:00
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
2025-01-09 18:07:02 +02:00
res.setHeader("Content-Type", note.mime);
2022-01-08 12:01:54 +01:00
res.send(note.getContent());
});
2025-01-09 18:07:02 +02:00
eu.route(router, "put", "/etapi/notes/:noteId/content", (req, res, next) => {
2022-01-10 17:09:20 +01:00
const note = eu.getAndCheckNote(req.params.noteId);
2022-01-08 12:01:54 +01:00
2023-06-05 09:23:42 +02:00
if (note.isProtected) {
throw new eu.EtapiError(400, "NOTE_IS_PROTECTED", `Note '${req.params.noteId}' is protected and cannot be modified through ETAPI.`);
}
2022-01-08 12:01:54 +01:00
note.setContent(req.body);
noteService.asyncPostProcessContent(note, req.body);
2022-01-08 12:01:54 +01:00
return res.sendStatus(204);
});
2022-07-24 21:30:29 +02:00
2025-01-09 18:07:02 +02:00
eu.route(router, "get", "/etapi/notes/:noteId/export", (req, res, next) => {
2022-07-24 21:30:29 +02:00
const note = eu.getAndCheckNote(req.params.noteId);
const format = req.query.format || "html";
2024-04-07 16:56:45 +03:00
if (typeof format !== "string" || !["html", "markdown"].includes(format)) {
2023-06-05 09:23:42 +02:00
throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`);
2022-07-24 21:30:29 +02:00
}
2025-01-09 18:07:02 +02:00
const taskContext = new TaskContext("no-progress-reporting");
2022-07-24 21:30:29 +02:00
// 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.
const branch = note.getParentBranches()[0];
2024-04-07 16:56:45 +03:00
zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res);
2022-07-24 21:30:29 +02:00
});
2025-01-09 18:07:02 +02:00
eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => {
2023-06-15 23:21:40 +02:00
const note = eu.getAndCheckNote(req.params.noteId);
2025-01-09 18:07:02 +02:00
const taskContext = new TaskContext("no-progress-reporting");
2023-06-15 23:21:40 +02:00
2025-01-09 18:07:02 +02:00
zipImportService.importZip(taskContext, req.body, note).then((importedNote) => {
2023-06-15 23:21:40 +02:00
res.status(201).json({
note: mappers.mapNoteToPojo(importedNote),
2025-01-09 18:07:02 +02:00
branch: mappers.mapBranchToPojo(importedNote.getParentBranches()[0])
2023-06-15 23:21:40 +02:00
});
}); // we need better error handling here, async errors won't be properly processed.
});
2025-01-09 18:07:02 +02:00
eu.route(router, "post", "/etapi/notes/:noteId/revision", (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
note.saveRevision();
return res.sendStatus(204);
});
2023-06-05 09:23:42 +02:00
2025-01-09 18:07:02 +02:00
eu.route(router, "get", "/etapi/notes/:noteId/attachments", (req, res, next) => {
2023-06-05 09:23:42 +02:00
const note = eu.getAndCheckNote(req.params.noteId);
2025-01-09 18:07:02 +02:00
const attachments = note.getAttachments({ includeContentLength: true });
2023-06-05 09:23:42 +02:00
2025-01-09 18:07:02 +02:00
res.json(attachments.map((attachment) => mappers.mapAttachmentToPojo(attachment)));
2023-06-05 09:23:42 +02:00
});
2022-01-07 19:33:59 +01:00
}
function parseSearchParams(req: Request) {
2024-04-07 16:56:45 +03:00
const rawSearchParams: SearchParams = {
2025-01-09 18:07:02 +02:00
fastSearch: parseBoolean(req.query, "fastSearch"),
includeArchivedNotes: parseBoolean(req.query, "includeArchivedNotes"),
ancestorNoteId: parseString(req.query["ancestorNoteId"]),
ancestorDepth: parseString(req.query["ancestorDepth"]), // e.g. "eq5"
orderBy: parseString(req.query["orderBy"]),
2024-04-07 16:56:45 +03:00
// TODO: Check why the order direction was provided as a number, but it's a string everywhere else.
2025-01-09 18:07:02 +02:00
orderDirection: parseOrderDirection(req.query, "orderDirection") as unknown as string,
limit: parseInteger(req.query, "limit"),
debug: parseBoolean(req.query, "debug")
2022-01-08 13:18:12 +01:00
};
2024-04-07 16:56:45 +03:00
const searchParams: SearchParams = {};
2022-01-08 13:18:12 +01:00
2024-04-07 16:56:45 +03:00
for (const paramName of Object.keys(rawSearchParams) as (keyof SearchParams)[]) {
2022-01-08 13:18:12 +01:00
if (rawSearchParams[paramName] !== undefined) {
2024-04-07 16:56:45 +03:00
(searchParams as any)[paramName] = rawSearchParams[paramName];
2022-01-08 13:18:12 +01:00
}
}
return searchParams;
}
const SEARCH_PARAM_ERROR = "SEARCH_PARAM_VALIDATION_ERROR";
2024-04-07 16:56:45 +03:00
function parseString(value: string | ParsedQs | string[] | ParsedQs[] | undefined): string | undefined {
if (typeof value === "string") {
return value;
}
return undefined;
}
function parseBoolean(obj: any, name: string) {
2022-01-08 13:18:12 +01:00
if (!(name in obj)) {
return undefined;
}
2025-01-09 18:07:02 +02:00
if (!["true", "false"].includes(obj[name])) {
2023-06-05 09:23:42 +02:00
throw new eu.EtapiError(400, SEARCH_PARAM_ERROR, `Cannot parse boolean '${name}' value '${obj[name]}, allowed values are 'true' and 'false'.`);
2022-01-08 13:18:12 +01:00
}
2025-01-09 18:07:02 +02:00
return obj[name] === "true";
2022-01-08 13:18:12 +01:00
}
2024-04-07 16:56:45 +03:00
function parseOrderDirection(obj: any, name: string) {
2024-04-16 21:10:39 +03:00
if (!(name in obj)) {
return undefined;
}
2022-01-08 13:18:12 +01:00
const integer = parseInt(obj[name]);
2025-01-09 18:07:02 +02:00
if (!["asc", "desc"].includes(obj[name])) {
2023-06-05 09:23:42 +02:00
throw new eu.EtapiError(400, SEARCH_PARAM_ERROR, `Cannot parse order direction value '${obj[name]}, allowed values are 'asc' and 'desc'.`);
2022-01-08 13:18:12 +01:00
}
return integer;
}
2024-04-07 16:56:45 +03:00
function parseInteger(obj: any, name: string) {
2022-01-08 13:18:12 +01:00
if (!(name in obj)) {
return undefined;
}
const integer = parseInt(obj[name]);
if (Number.isNaN(integer)) {
2023-06-05 09:23:42 +02:00
throw new eu.EtapiError(400, SEARCH_PARAM_ERROR, `Cannot parse integer '${name}' value '${obj[name]}'.`);
2022-01-08 13:18:12 +01:00
}
return integer;
}
export default {
2022-01-07 19:33:59 +01:00
register
2022-01-07 23:06:04 +01:00
};