diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index ebd3244f0..55fa17845 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -1,3 +1,4 @@ +import { routes } from "@triliumnext/core"; import { createPartialContentHandler } from "@triliumnext/express-partial-content"; import express from "express"; import rateLimit from "express-rate-limit"; @@ -21,7 +22,6 @@ import appInfoRoute from "./api/app_info.js"; import attributesRoute from "./api/attributes.js"; import autocompleteApiRoute from "./api/autocomplete.js"; import backendLogRoute from "./api/backend_log.js"; -import branchesApiRoute from "./api/branches.js"; import bulkActionRoute from "./api/bulk_action.js"; import clipperRoute from "./api/clipper.js"; import cloningApiRoute from "./api/cloning.js"; @@ -62,7 +62,6 @@ import loginRoute from "./login.js"; import { apiResultHandler, apiRoute, asyncApiRoute, asyncRoute, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js"; // page routes import setupRoute from "./setup.js"; -import { routes } from "@triliumnext/core"; const GET = "get", PST = "post", @@ -124,15 +123,6 @@ function register(app: express.Application) { apiRoute(PST, "/api/notes/:noteId/save-to-tmp-dir", filesRoute.saveNoteToTmpDir); apiRoute(PST, "/api/notes/:noteId/upload-modified-file", filesRoute.uploadModifiedFileToNote); - apiRoute(PUT, "/api/branches/:branchId/move-to/:parentBranchId", branchesApiRoute.moveBranchToParent); - apiRoute(PUT, "/api/branches/:branchId/move-before/:beforeBranchId", branchesApiRoute.moveBranchBeforeNote); - apiRoute(PUT, "/api/branches/:branchId/move-after/:afterBranchId", branchesApiRoute.moveBranchAfterNote); - apiRoute(PUT, "/api/branches/:branchId/expanded/:expanded", branchesApiRoute.setExpanded); - apiRoute(PUT, "/api/branches/:branchId/expanded-subtree/:expanded", branchesApiRoute.setExpandedForSubtree); - apiRoute(DEL, "/api/branches/:branchId", branchesApiRoute.deleteBranch); - apiRoute(PUT, "/api/branches/:branchId/set-prefix", branchesApiRoute.setPrefix); - apiRoute(PUT, "/api/branches/set-prefix-batch", branchesApiRoute.setPrefixBatch); - // TODO: Bring back attachment uploading // route(PST, "/api/notes/:noteId/attachments/upload", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], attachmentsApiRoute.uploadAttachment, apiResultHandler); route(GET, "/api/attachments/:attachmentId/image/:filename", [auth.checkApiAuthOrElectron], imageRoute.returnAttachedImage); diff --git a/apps/server/src/services/backend_script_api.ts b/apps/server/src/services/backend_script_api.ts index 5d1d86331..6fb3e24ec 100644 --- a/apps/server/src/services/backend_script_api.ts +++ b/apps/server/src/services/backend_script_api.ts @@ -1,5 +1,5 @@ import { type AttributeRow, dayjs, formatLogMessage } from "@triliumnext/commons"; -import { type AbstractBeccaEntity, Becca, NoteParams } from "@triliumnext/core"; +import { type AbstractBeccaEntity, Becca, branches as branchService, NoteParams } from "@triliumnext/core"; import axios from "axios"; import * as cheerio from "cheerio"; import xml2js from "xml2js"; @@ -16,7 +16,6 @@ import appInfo from "./app_info.js"; import attributeService from "./attributes.js"; import type { ApiParams } from "./backend_script_api_interface.js"; import backupService from "./backup.js"; -import branchService from "./branches.js"; import cloningService from "./cloning.js"; import config from "./config.js"; import dateNoteService from "./date_notes.js"; diff --git a/apps/server/src/services/bulk_actions.ts b/apps/server/src/services/bulk_actions.ts index 2c662fba2..7387dc64a 100644 --- a/apps/server/src/services/bulk_actions.ts +++ b/apps/server/src/services/bulk_actions.ts @@ -1,9 +1,8 @@ import { ActionHandlers, BulkAction, BulkActionData } from "@triliumnext/commons"; -import { erase as eraseService } from "@triliumnext/core"; +import { branches as branchService, erase as eraseService } from "@triliumnext/core"; import becca from "../becca/becca.js"; import type BNote from "../becca/entities/bnote.js"; -import branchService from "./branches.js"; import cloningService from "./cloning.js"; import log from "./log.js"; import { randomString } from "./utils.js"; diff --git a/apps/server/src/services/utils.ts b/apps/server/src/services/utils.ts index 86cfd87f3..cf7911531 100644 --- a/apps/server/src/services/utils.ts +++ b/apps/server/src/services/utils.ts @@ -87,11 +87,6 @@ export function constantTimeCompare(a: string | null | undefined, b: string | nu return crypto.timingSafeEqual(bufA, bufB); } -export function isEmptyOrWhitespace(str: string | null | undefined) { - if (!str) return true; - return str.match(/^ *$/) !== null; -} - export function sanitizeSqlIdentifier(str: string) { return str.replace(/[^A-Za-z0-9_]/g, ""); } @@ -457,6 +452,8 @@ export const unescapeHtml = coreUtils.unescapeHtml; export const randomSecureToken = coreUtils.randomSecureToken; /** @deprecated */ export const safeExtractMessageAndStackFromError = coreUtils.safeExtractMessageAndStackFromError; +/** @deprecated */ +export const isEmptyOrWhitespace = coreUtils.isEmptyOrWhitespace; export default { compareVersions, diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 001e21c16..de18e947c 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -37,6 +37,7 @@ export { default as TaskContext } from "./services/task_context"; export { default as revisions } from "./services/revisions"; export { default as erase } from "./services/erase"; export { default as getSharedBootstrapItems } from "./services/bootstrap_utils"; +export { default as branches } from "./services/branches"; // Messaging system export * from "./services/messaging/index"; diff --git a/apps/server/src/routes/api/branches.ts b/packages/trilium-core/src/routes/api/branches.ts similarity index 86% rename from apps/server/src/routes/api/branches.ts rename to packages/trilium-core/src/routes/api/branches.ts index 26f030e47..86c926a7c 100644 --- a/apps/server/src/routes/api/branches.ts +++ b/packages/trilium-core/src/routes/api/branches.ts @@ -1,14 +1,16 @@ -import { erase as eraseService, events as eventService, ValidationError } from "@triliumnext/core"; +import branchService from "../../services/branches.js"; +import eraseService from "../../services/erase.js"; +import eventService from "../../services/events.js"; import type { Request } from "express"; import becca from "../../becca/becca.js"; -import branchService from "../../services/branches.js"; import entityChangesService from "../../services/entity_changes.js"; -import log from "../../services/log.js"; -import sql from "../../services/sql.js"; +import { getLog } from "../../services/log.js"; import TaskContext from "../../services/task_context.js"; import treeService from "../../services/tree.js"; -import utils from "../../services/utils.js"; +import { isEmptyOrWhitespace, randomString } from "../../services/utils/index.js"; +import { getSql } from "../../services/sql/index.js"; +import { ValidationError } from "../../errors.js"; /** * Code in this file deals with moving and cloning branches. The relationship between note and parent note is unique @@ -45,7 +47,7 @@ function moveBranchBeforeNote(req: Request) { // we don't change utcDateModified, so other changes are prioritized in case of conflict // also we would have to sync all those modified branches otherwise hash checks would fail - sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition >= ? AND isDeleted = 0", [beforeBranch.parentNoteId, originalBeforeNotePosition]); + getSql().execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition >= ? AND isDeleted = 0", [beforeBranch.parentNoteId, originalBeforeNotePosition]); // also need to update becca positions const parentNote = becca.getNoteOrThrow(beforeBranch.parentNoteId); @@ -71,7 +73,7 @@ function moveBranchBeforeNote(req: Request) { // if sorting is not needed, then still the ordering might have changed above manually entityChangesService.putNoteReorderingEntityChange(parentNote.noteId); - log.info(`Moved note ${branchToMove.noteId}, branch ${branchId} before note ${beforeBranch.noteId}, branch ${beforeBranchId}`); + getLog().info(`Moved note ${branchToMove.noteId}, branch ${branchId} before note ${beforeBranch.noteId}, branch ${beforeBranchId}`); return { success: true }; } @@ -92,7 +94,7 @@ function moveBranchAfterNote(req: Request) { // we don't change utcDateModified, so other changes are prioritized in case of conflict // also we would have to sync all those modified branches otherwise hash checks would fail - sql.execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, originalAfterNotePosition]); + getSql().execute("UPDATE branches SET notePosition = notePosition + 10 WHERE parentNoteId = ? AND notePosition > ? AND isDeleted = 0", [afterNote.parentNoteId, originalAfterNotePosition]); // also need to update becca positions const parentNote = becca.getNoteOrThrow(afterNote.parentNoteId); @@ -120,7 +122,7 @@ function moveBranchAfterNote(req: Request) { // if sorting is not needed, then still the ordering might have changed above manually entityChangesService.putNoteReorderingEntityChange(parentNote.noteId); - log.info(`Moved note ${branchToMove.noteId}, branch ${branchId} after note ${afterNote.noteId}, branch ${afterBranchId}`); + getLog().info(`Moved note ${branchToMove.noteId}, branch ${branchId} after note ${afterNote.noteId}, branch ${afterBranchId}`); return { success: true }; } @@ -130,7 +132,7 @@ function setExpanded(req: Request) { const expanded = parseInt(req.params.expanded); if (branchId !== "none_root") { - sql.execute("UPDATE branches SET isExpanded = ? WHERE branchId = ?", [expanded, branchId]); + getSql().execute("UPDATE branches SET isExpanded = ? WHERE branchId = ?", [expanded, branchId]); // we don't sync expanded label // also this does not trigger updates to the frontend, this would trigger too many reloads @@ -150,6 +152,7 @@ function setExpanded(req: Request) { function setExpandedForSubtree(req: Request) { const { branchId } = req.params; const expanded = parseInt(req.params.expanded); + const sql = getSql(); let branchIds = sql.getColumn( ` @@ -236,7 +239,7 @@ function deleteBranch(req: Request) { const taskContext = TaskContext.getInstance(req.query.taskId as string, "deleteNotes", null); - const deleteId = utils.randomString(10); + const deleteId = randomString(10); let noteDeleted; if (eraseNotes) { @@ -260,7 +263,7 @@ function deleteBranch(req: Request) { function setPrefix(req: Request) { const branchId = req.params.branchId; //TriliumNextTODO: req.body arrives as string, so req.body.prefix will be undefined – did the code below ever even work? - const prefix = utils.isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix; + const prefix = isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix; const branch = becca.getBranchOrThrow(branchId); branch.prefix = prefix; @@ -279,7 +282,7 @@ function setPrefixBatch(req: Request) { throw new ValidationError("prefix must be a string or null"); } - const normalizedPrefix = utils.isEmptyOrWhitespace(prefix) ? null : prefix; + const normalizedPrefix = isEmptyOrWhitespace(prefix) ? null : prefix; let updatedCount = 0; for (const branchId of branchIds) { @@ -289,7 +292,7 @@ function setPrefixBatch(req: Request) { branch.save(); updatedCount++; } else { - log.info(`Branch ${branchId} not found, skipping prefix update`); + getLog().info(`Branch ${branchId} not found, skipping prefix update`); } } diff --git a/packages/trilium-core/src/routes/index.ts b/packages/trilium-core/src/routes/index.ts index 7ee403724..b87f85b98 100644 --- a/packages/trilium-core/src/routes/index.ts +++ b/packages/trilium-core/src/routes/index.ts @@ -6,6 +6,7 @@ import attachmentsApiRoute from "./api/attachments"; import noteMapRoute from "./api/note_map"; import recentNotesRoute from "./api/recent_notes"; import otherRoute from "./api/others"; +import branchesApiRoute from "./api/branches"; import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity"; // TODO: Deduplicate with routes.ts @@ -53,6 +54,15 @@ export function buildSharedApiRoutes(apiRoute: any) { apiRoute(PUT, "/api/attachments/:attachmentId/rename", attachmentsApiRoute.renameAttachment); apiRoute(GET, "/api/attachments/:attachmentId/blob", attachmentsApiRoute.getAttachmentBlob); + apiRoute(PUT, "/api/branches/:branchId/move-to/:parentBranchId", branchesApiRoute.moveBranchToParent); + apiRoute(PUT, "/api/branches/:branchId/move-before/:beforeBranchId", branchesApiRoute.moveBranchBeforeNote); + apiRoute(PUT, "/api/branches/:branchId/move-after/:afterBranchId", branchesApiRoute.moveBranchAfterNote); + apiRoute(PUT, "/api/branches/:branchId/expanded/:expanded", branchesApiRoute.setExpanded); + apiRoute(PUT, "/api/branches/:branchId/expanded-subtree/:expanded", branchesApiRoute.setExpandedForSubtree); + apiRoute(DEL, "/api/branches/:branchId", branchesApiRoute.deleteBranch); + apiRoute(PUT, "/api/branches/:branchId/set-prefix", branchesApiRoute.setPrefix); + apiRoute(PUT, "/api/branches/set-prefix-batch", branchesApiRoute.setPrefixBatch); + apiRoute(GET, "/api/note-map/:noteId/backlink-count", noteMapRoute.getBacklinkCount); apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote); diff --git a/packages/trilium-core/src/services/branches.ts b/packages/trilium-core/src/services/branches.ts new file mode 100644 index 000000000..3b27de522 --- /dev/null +++ b/packages/trilium-core/src/services/branches.ts @@ -0,0 +1,50 @@ +import treeService from "./tree.js"; +import type BBranch from "../becca/entities/bbranch.js"; +import { getSql } from "./sql/index.js"; + +function moveBranchToNote(branchToMove: BBranch, targetParentNoteId: string) { + if (branchToMove.parentNoteId === targetParentNoteId) { + return { success: true }; // no-op + } + + const validationResult = treeService.validateParentChild(targetParentNoteId, branchToMove.noteId, branchToMove.branchId); + + if (!validationResult.success) { + return [200, validationResult]; + } + + const maxNotePos = getSql().getValue("SELECT MAX(notePosition) FROM branches WHERE parentNoteId = ? AND isDeleted = 0", [targetParentNoteId]); + const newNotePos = !maxNotePos ? 0 : maxNotePos + 10; + + const newBranch = branchToMove.createClone(targetParentNoteId, newNotePos); + newBranch.save(); + + branchToMove.markAsDeleted(); + + return { + success: true, + branch: newBranch + }; +} + +function moveBranchToBranch(branchToMove: BBranch, targetParentBranch: BBranch, branchId: string) { + // TODO: Unused branch ID argument. + const res = moveBranchToNote(branchToMove, targetParentBranch.noteId); + + if (!("success" in res) || !res.success) { + return res; + } + + // expanding so that the new placement of the branch is immediately visible + if (!targetParentBranch.isExpanded) { + targetParentBranch.isExpanded = true; + targetParentBranch.save(); + } + + return res; +} + +export default { + moveBranchToBranch, + moveBranchToNote +}; diff --git a/packages/trilium-core/src/services/utils/index.ts b/packages/trilium-core/src/services/utils/index.ts index f172452ff..bba53a0b9 100644 --- a/packages/trilium-core/src/services/utils/index.ts +++ b/packages/trilium-core/src/services/utils/index.ts @@ -130,3 +130,8 @@ export function randomSecureToken(bytes = 32) { export function safeExtractMessageAndStackFromError(err: unknown): [errMessage: string, errStack: string | undefined] { return (err instanceof Error) ? [err.message, err.stack] as const : ["Unknown Error", undefined] as const; } + +export function isEmptyOrWhitespace(str: string | null | undefined) { + if (!str) return true; + return str.match(/^ *$/) !== null; +}