diff --git a/apps/client/src/menus/tree_context_menu.ts b/apps/client/src/menus/tree_context_menu.ts index 6504b49eb..7384573d8 100644 --- a/apps/client/src/menus/tree_context_menu.ts +++ b/apps/client/src/menus/tree_context_menu.ts @@ -137,7 +137,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener(); + const [ branches, setBranches ] = useState([]); const [ prefix, setPrefix ] = useState(""); const branchInput = useRef(null); - useTriliumEvent("editBranchPrefix", async () => { - const notePath = appContext.tabManager.getActiveContextNotePath(); - if (!notePath) { + useTriliumEvent("editBranchPrefix", async (data?: ContextMenuCommandData) => { + let branchIds: string[] = []; + + if (data?.selectedOrActiveBranchIds && data.selectedOrActiveBranchIds.length > 0) { + // Multi-select mode from tree context menu + branchIds = data.selectedOrActiveBranchIds.filter((branchId) => !branchId.startsWith(VIRTUAL_BRANCH_PREFIX)); + } else { + // Single branch mode from keyboard shortcut or when no selection + const notePath = appContext.tabManager.getActiveContextNotePath(); + if (!notePath) { + return; + } + + const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath); + + if (!noteId || !parentNoteId) { + return; + } + + const branchId = await froca.getBranchId(parentNoteId, noteId); + if (!branchId) { + return; + } + const parentNote = await froca.getNote(parentNoteId); + if (!parentNote || parentNote.type === "search") { + return; + } + + branchIds = [branchId]; + } + + if (branchIds.length === 0) { return; } - const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath); + const newBranches = branchIds + .map(id => froca.getBranch(id)) + .filter((branch): branch is FBranch => branch !== null); - if (!noteId || !parentNoteId) { + if (newBranches.length === 0) { return; } - const newBranchId = await froca.getBranchId(parentNoteId, noteId); - if (!newBranchId) { - return; - } - const parentNote = await froca.getNote(parentNoteId); - if (!parentNote || parentNote.type === "search") { - return; - } - - const newBranch = froca.getBranch(newBranchId); - setBranch(newBranch); - setPrefix(newBranch?.prefix ?? ""); + setBranches(newBranches); + // Use the prefix of the first branch as the initial value + setPrefix(newBranches[0]?.prefix ?? ""); setShown(true); }); async function onSubmit() { - if (!branch) { + if (branches.length === 0) { return; } - savePrefix(branch.branchId, prefix); + if (branches.length === 1) { + await savePrefix(branches[0].branchId, prefix); + } else { + await savePrefixBatch(branches.map(b => b.branchId), prefix); + } setShown(false); } + const isSingleBranch = branches.length === 1; + return ( branchInput.current?.focus()} onHidden={() => setShown(false)} @@ -69,9 +102,27 @@ export default function BranchPrefixDialog() {
setPrefix((e.target as HTMLInputElement).value)} /> -
- {branch && branch.getNoteFromCache().title}
+ {isSingleBranch && branches[0] && ( +
- {branches[0].getNoteFromCache().title}
+ )}
+ {!isSingleBranch && ( +
+ {t("branch_prefix.affected_branches", { count: branches.length })} +
    + {branches.map((branch) => { + const note = branch.getNoteFromCache(); + return ( +
  • + {branch.prefix && {branch.prefix} - } + {note.title} +
  • + ); + })} +
+
+ )}
); } @@ -80,3 +131,8 @@ async function savePrefix(branchId: string, prefix: string) { await server.put(`branches/${branchId}/set-prefix`, { prefix: prefix }); toast.showMessage(t("branch_prefix.branch_prefix_saved")); } + +async function savePrefixBatch(branchIds: string[], prefix: string) { + await server.put("branches/set-prefix-batch", { branchIds, prefix }); + toast.showMessage(t("branch_prefix.branch_prefix_saved_multiple", { count: branchIds.length })); +} diff --git a/apps/client/src/widgets/note_tree.ts b/apps/client/src/widgets/note_tree.ts index f1c2ca736..cb2120687 100644 --- a/apps/client/src/widgets/note_tree.ts +++ b/apps/client/src/widgets/note_tree.ts @@ -1591,6 +1591,20 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { this.clearSelectedNodes(); } + async editBranchPrefixCommand({ node }: CommandListenerData<"editBranchPrefix">) { + const branchIds = this.getSelectedOrActiveBranchIds(node).filter((branchId) => !branchId.startsWith("virt-")); + + if (!branchIds.length) { + return; + } + + // Trigger the event with the selected branch IDs + appContext.triggerEvent("editBranchPrefix", { + selectedOrActiveBranchIds: branchIds, + node: node + }); + } + canBeMovedUpOrDown(node: Fancytree.FancytreeNode) { if (node.data.noteId === "root") { return false; diff --git a/apps/server/src/routes/api/branches.ts b/apps/server/src/routes/api/branches.ts index 810deaba7..73ce03a7a 100644 --- a/apps/server/src/routes/api/branches.ts +++ b/apps/server/src/routes/api/branches.ts @@ -270,6 +270,38 @@ function setPrefix(req: Request) { branch.save(); } +function setPrefixBatch(req: Request) { + const { branchIds, prefix } = req.body; + + if (!Array.isArray(branchIds)) { + throw new ValidationError("branchIds must be an array"); + } + + // Validate that prefix is a string or null/undefined to prevent prototype pollution + if (prefix !== null && prefix !== undefined && typeof prefix !== 'string') { + throw new ValidationError("prefix must be a string or null"); + } + + const normalizedPrefix = utils.isEmptyOrWhitespace(prefix) ? null : prefix; + let updatedCount = 0; + + for (const branchId of branchIds) { + const branch = becca.getBranch(branchId); + if (branch) { + branch.prefix = normalizedPrefix; + branch.save(); + updatedCount++; + } else { + log.info(`Branch ${branchId} not found, skipping prefix update`); + } + } + + return { + success: true, + count: updatedCount + }; +} + export default { moveBranchToParent, moveBranchBeforeNote, @@ -277,5 +309,6 @@ export default { setExpanded, setExpandedForSubtree, deleteBranch, - setPrefix + setPrefix, + setPrefixBatch }; diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index 9074789f2..78a1380b7 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -154,6 +154,7 @@ function register(app: express.Application) { 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/notes/:noteId/attachments", attachmentsApiRoute.getAttachments); apiRoute(PST, "/api/notes/:noteId/attachments", attachmentsApiRoute.saveAttachment);