Skip to content

Commit 97e8774

Browse files
authored
Add multi-branch prefix editing support (#7598)
2 parents d521bda + 193caf8 commit 97e8774

File tree

7 files changed

+143
-23
lines changed

7 files changed

+143
-23
lines changed

apps/client/src/menus/tree_context_menu.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
137137
command: "editBranchPrefix",
138138
keyboardShortcut: "editBranchPrefix",
139139
uiIcon: "bx bx-rename",
140-
enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
140+
enabled: isNotRoot && parentNotSearch && notOptionsOrHelp
141141
},
142142
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
143143

apps/client/src/translations/en/translation.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,13 @@
3636
},
3737
"branch_prefix": {
3838
"edit_branch_prefix": "Edit branch prefix",
39+
"edit_branch_prefix_multiple": "Edit branch prefix for {{count}} branches",
3940
"help_on_tree_prefix": "Help on Tree prefix",
4041
"prefix": "Prefix: ",
4142
"save": "Save",
42-
"branch_prefix_saved": "Branch prefix has been saved."
43+
"branch_prefix_saved": "Branch prefix has been saved.",
44+
"branch_prefix_saved_multiple": "Branch prefix has been saved for {{count}} branches.",
45+
"affected_branches": "Affected branches ({{count}}):"
4346
},
4447
"bulk_actions": {
4548
"bulk_actions": "Bulk actions",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.branch-prefix-dialog .branch-prefix-notes-list {
2+
margin-top: 10px;
3+
}
4+
5+
.branch-prefix-dialog .branch-prefix-notes-list ul {
6+
max-height: 200px;
7+
overflow: auto;
8+
margin-top: 5px;
9+
}
10+
11+
.branch-prefix-dialog .branch-prefix-current {
12+
opacity: 0.6;
13+
}

apps/client/src/widgets/dialogs/branch_prefix.tsx

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,53 +10,86 @@ import Button from "../react/Button.jsx";
1010
import FormGroup from "../react/FormGroup.js";
1111
import { useTriliumEvent } from "../react/hooks.jsx";
1212
import FBranch from "../../entities/fbranch.js";
13+
import type { ContextMenuCommandData } from "../../components/app_context.js";
14+
import "./branch_prefix.css";
15+
16+
// Virtual branches (e.g., from search results) start with this prefix
17+
const VIRTUAL_BRANCH_PREFIX = "virt-";
1318

1419
export default function BranchPrefixDialog() {
1520
const [ shown, setShown ] = useState(false);
16-
const [ branch, setBranch ] = useState<FBranch>();
21+
const [ branches, setBranches ] = useState<FBranch[]>([]);
1722
const [ prefix, setPrefix ] = useState("");
1823
const branchInput = useRef<HTMLInputElement>(null);
1924

20-
useTriliumEvent("editBranchPrefix", async () => {
21-
const notePath = appContext.tabManager.getActiveContextNotePath();
22-
if (!notePath) {
23-
return;
24-
}
25+
useTriliumEvent("editBranchPrefix", async (data?: ContextMenuCommandData) => {
26+
let branchIds: string[] = [];
2527

26-
const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
28+
if (data?.selectedOrActiveBranchIds && data.selectedOrActiveBranchIds.length > 0) {
29+
// Multi-select mode from tree context menu
30+
branchIds = data.selectedOrActiveBranchIds.filter((branchId) => !branchId.startsWith(VIRTUAL_BRANCH_PREFIX));
31+
} else {
32+
// Single branch mode from keyboard shortcut or when no selection
33+
const notePath = appContext.tabManager.getActiveContextNotePath();
34+
if (!notePath) {
35+
return;
36+
}
2737

28-
if (!noteId || !parentNoteId) {
29-
return;
38+
const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath);
39+
40+
if (!noteId || !parentNoteId) {
41+
return;
42+
}
43+
44+
const branchId = await froca.getBranchId(parentNoteId, noteId);
45+
if (!branchId) {
46+
return;
47+
}
48+
const parentNote = await froca.getNote(parentNoteId);
49+
if (!parentNote || parentNote.type === "search") {
50+
return;
51+
}
52+
53+
branchIds = [branchId];
3054
}
3155

32-
const newBranchId = await froca.getBranchId(parentNoteId, noteId);
33-
if (!newBranchId) {
56+
if (branchIds.length === 0) {
3457
return;
3558
}
36-
const parentNote = await froca.getNote(parentNoteId);
37-
if (!parentNote || parentNote.type === "search") {
59+
60+
const newBranches = branchIds
61+
.map(id => froca.getBranch(id))
62+
.filter((branch): branch is FBranch => branch !== null);
63+
64+
if (newBranches.length === 0) {
3865
return;
3966
}
4067

41-
const newBranch = froca.getBranch(newBranchId);
42-
setBranch(newBranch);
43-
setPrefix(newBranch?.prefix ?? "");
68+
setBranches(newBranches);
69+
// Use the prefix of the first branch as the initial value
70+
setPrefix(newBranches[0]?.prefix ?? "");
4471
setShown(true);
4572
});
4673

4774
async function onSubmit() {
48-
if (!branch) {
75+
if (branches.length === 0) {
4976
return;
5077
}
5178

52-
savePrefix(branch.branchId, prefix);
79+
if (branches.length === 1) {
80+
await savePrefix(branches[0].branchId, prefix);
81+
} else {
82+
await savePrefixBatch(branches.map(b => b.branchId), prefix);
83+
}
5384
setShown(false);
5485
}
5586

87+
const isSingleBranch = branches.length === 1;
88+
5689
return (
5790
<Modal
5891
className="branch-prefix-dialog"
59-
title={t("branch_prefix.edit_branch_prefix")}
92+
title={isSingleBranch ? t("branch_prefix.edit_branch_prefix") : t("branch_prefix.edit_branch_prefix_multiple", { count: branches.length })}
6093
size="lg"
6194
onShown={() => branchInput.current?.focus()}
6295
onHidden={() => setShown(false)}
@@ -69,9 +102,27 @@ export default function BranchPrefixDialog() {
69102
<div class="input-group">
70103
<input class="branch-prefix-input form-control" value={prefix} ref={branchInput}
71104
onChange={(e) => setPrefix((e.target as HTMLInputElement).value)} />
72-
<div class="branch-prefix-note-title input-group-text"> - {branch && branch.getNoteFromCache().title}</div>
105+
{isSingleBranch && branches[0] && (
106+
<div class="branch-prefix-note-title input-group-text"> - {branches[0].getNoteFromCache().title}</div>
107+
)}
73108
</div>
74109
</FormGroup>
110+
{!isSingleBranch && (
111+
<div className="branch-prefix-notes-list">
112+
<strong>{t("branch_prefix.affected_branches", { count: branches.length })}</strong>
113+
<ul>
114+
{branches.map((branch) => {
115+
const note = branch.getNoteFromCache();
116+
return (
117+
<li key={branch.branchId}>
118+
{branch.prefix && <span className="branch-prefix-current">{branch.prefix} - </span>}
119+
{note.title}
120+
</li>
121+
);
122+
})}
123+
</ul>
124+
</div>
125+
)}
75126
</Modal>
76127
);
77128
}
@@ -80,3 +131,8 @@ async function savePrefix(branchId: string, prefix: string) {
80131
await server.put(`branches/${branchId}/set-prefix`, { prefix: prefix });
81132
toast.showMessage(t("branch_prefix.branch_prefix_saved"));
82133
}
134+
135+
async function savePrefixBatch(branchIds: string[], prefix: string) {
136+
await server.put("branches/set-prefix-batch", { branchIds, prefix });
137+
toast.showMessage(t("branch_prefix.branch_prefix_saved_multiple", { count: branchIds.length }));
138+
}

apps/client/src/widgets/note_tree.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,6 +1591,20 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
15911591
this.clearSelectedNodes();
15921592
}
15931593

1594+
async editBranchPrefixCommand({ node }: CommandListenerData<"editBranchPrefix">) {
1595+
const branchIds = this.getSelectedOrActiveBranchIds(node).filter((branchId) => !branchId.startsWith("virt-"));
1596+
1597+
if (!branchIds.length) {
1598+
return;
1599+
}
1600+
1601+
// Trigger the event with the selected branch IDs
1602+
appContext.triggerEvent("editBranchPrefix", {
1603+
selectedOrActiveBranchIds: branchIds,
1604+
node: node
1605+
});
1606+
}
1607+
15941608
canBeMovedUpOrDown(node: Fancytree.FancytreeNode) {
15951609
if (node.data.noteId === "root") {
15961610
return false;

apps/server/src/routes/api/branches.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,45 @@ function setPrefix(req: Request) {
270270
branch.save();
271271
}
272272

273+
function setPrefixBatch(req: Request) {
274+
const { branchIds, prefix } = req.body;
275+
276+
if (!Array.isArray(branchIds)) {
277+
throw new ValidationError("branchIds must be an array");
278+
}
279+
280+
// Validate that prefix is a string or null/undefined to prevent prototype pollution
281+
if (prefix !== null && prefix !== undefined && typeof prefix !== 'string') {
282+
throw new ValidationError("prefix must be a string or null");
283+
}
284+
285+
const normalizedPrefix = utils.isEmptyOrWhitespace(prefix) ? null : prefix;
286+
let updatedCount = 0;
287+
288+
for (const branchId of branchIds) {
289+
const branch = becca.getBranch(branchId);
290+
if (branch) {
291+
branch.prefix = normalizedPrefix;
292+
branch.save();
293+
updatedCount++;
294+
} else {
295+
log.info(`Branch ${branchId} not found, skipping prefix update`);
296+
}
297+
}
298+
299+
return {
300+
success: true,
301+
count: updatedCount
302+
};
303+
}
304+
273305
export default {
274306
moveBranchToParent,
275307
moveBranchBeforeNote,
276308
moveBranchAfterNote,
277309
setExpanded,
278310
setExpandedForSubtree,
279311
deleteBranch,
280-
setPrefix
312+
setPrefix,
313+
setPrefixBatch
281314
};

apps/server/src/routes/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ function register(app: express.Application) {
154154
apiRoute(PUT, "/api/branches/:branchId/expanded-subtree/:expanded", branchesApiRoute.setExpandedForSubtree);
155155
apiRoute(DEL, "/api/branches/:branchId", branchesApiRoute.deleteBranch);
156156
apiRoute(PUT, "/api/branches/:branchId/set-prefix", branchesApiRoute.setPrefix);
157+
apiRoute(PUT, "/api/branches/set-prefix-batch", branchesApiRoute.setPrefixBatch);
157158

158159
apiRoute(GET, "/api/notes/:noteId/attachments", attachmentsApiRoute.getAttachments);
159160
apiRoute(PST, "/api/notes/:noteId/attachments", attachmentsApiRoute.saveAttachment);

0 commit comments

Comments
 (0)