Rename a tag in every note (bulk-edit job) (#101) #238

Closed
kayg wants to merge 0 commits from issue/101-rename-tag-3 into dev
Owner

Closes #101. Replaces #216 (the forge ignores pushes to an open PR). Rebased on current dev; adds what kayg asked for: merge and drag-to-merge.

Rename and merge a tag in every note

  • notes.rename-tag {from, to} renames a tag, and every tag nested under it (#old/x → #new/x), in every note: inline #tags and the frontmatter tags: field (all three shapes #168 reads and writes: tags: a, b, tags: [a, b], and - a lines). It runs as one #9 bulk-edit job: the plan is stored first, each note's room applies only the edits to the name (they merge with typing), it picks up after a crash, and notes.undo takes it all back as one step.
  • Renaming onto an existing tag merges the two. A note that has both keeps the tag once. In the text, the renamed #old goes with one space beside it. In tags:, the item goes with its comma or with its - item line. Repeats that were already there stay. A tag that sits right against other characters ((#old)) is renamed rather than removed, so words never get joined.
  • notes.tag-preview {tag} gives the count from the note list. Both actions are in contracts/actions.json and actions.gen.ts.
  • Never touched: code (fenced or inline), link targets, reference definitions, web addresses, <…>. The note list, search and the rename all use one scanner, so the sidebar count is exactly what a rename changes.

Web

  • To rename: Rename… in the tag's menu, or double-click the row. The row shows "N notes" while you type. Enter renames, and the row takes the new name in place. One toast offers Undo.
  • Drag a tag onto another to merge. Hold the dragged row right over another tag's row and that row lights up. On drop, the bar asks "Merge #a into #b? N notes" (Merge or Enter to go ahead, Cancel or Escape to back out), then one job does it, with Undo in the toast ("Merged #a into #b in N notes"). Dropping between rows still just moves the row. On the phone, the bar comes up over the places screen only to ask.
  • InlineRename fixes: it saves once (Enter followed by blur used to save twice, and Escape could still save), live list updates no longer reset the name you're typing, and the field is as wide as its text.

Bulk-job changes made along the way

  • Each batch's progress now goes to job_steps (history migration 4) instead of rewriting the whole plan.
  • notes.job answers from memory while a job runs.
  • Rooms that planning only read close right away (Cmd::Rest).

Tests

  • Rewriter: unit tests and property tests. One property builds notes from pieces with known right answers. One renames and then renames back on arbitrary text. One checks merges: afterwards the note has the same set of tags, never more of them, and none left under the old name.
  • Notes integration tests: rename + undo, typing during a rename is kept, a big rename never stalls typing, and a crash mid-job.
  • The real binary is killed with SIGKILL 4× during a 400-note rename; each tag is renamed exactly once.
  • e2e (Chromium and WebKit, tag-rename.spec.ts): rename with the count and live editor/sidebar, Undo, a change from another device while typing the name, Escape, drag-to-merge with the bar question, and Escape in the bar.

Perf numbers and screenshots are on #101.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T

Closes #101. Replaces #216 (the forge ignores pushes to an open PR). Rebased on current dev; adds what kayg asked for: merge and drag-to-merge. **Rename and merge a tag in every note** - `notes.rename-tag {from, to}` renames a tag, and every tag nested under it (`#old/x` → `#new/x`), in every note: inline `#tags` and the frontmatter `tags:` field (all three shapes #168 reads and writes: `tags: a, b`, `tags: [a, b]`, and `- a` lines). It runs as one #9 bulk-edit job: the plan is stored first, each note's room applies only the edits to the name (they merge with typing), it picks up after a crash, and `notes.undo` takes it all back as one step. - **Renaming onto an existing tag merges the two.** A note that has both keeps the tag once. In the text, the renamed `#old` goes with one space beside it. In `tags:`, the item goes with its comma or with its `- item` line. Repeats that were already there stay. A tag that sits right against other characters (`(#old)`) is renamed rather than removed, so words never get joined. - `notes.tag-preview {tag}` gives the count from the note list. Both actions are in contracts/actions.json and actions.gen.ts. - Never touched: code (fenced or inline), link targets, reference definitions, web addresses, `<…>`. The note list, search and the rename all use one scanner, so the sidebar count is exactly what a rename changes. **Web** - To rename: Rename… in the tag's menu, or double-click the row. The row shows "N notes" while you type. Enter renames, and the row takes the new name in place. One toast offers Undo. - **Drag a tag onto another to merge.** Hold the dragged row right over another tag's row and that row lights up. On drop, the bar asks "Merge #a into #b? N notes" (Merge or Enter to go ahead, Cancel or Escape to back out), then one job does it, with Undo in the toast ("Merged #a into #b in N notes"). Dropping between rows still just moves the row. On the phone, the bar comes up over the places screen only to ask. - InlineRename fixes: it saves once (Enter followed by blur used to save twice, and Escape could still save), live list updates no longer reset the name you're typing, and the field is as wide as its text. **Bulk-job changes made along the way** - Each batch's progress now goes to `job_steps` (history migration 4) instead of rewriting the whole plan. - `notes.job` answers from memory while a job runs. - Rooms that planning only read close right away (`Cmd::Rest`). **Tests** - Rewriter: unit tests and property tests. One property builds notes from pieces with known right answers. One renames and then renames back on arbitrary text. One checks merges: afterwards the note has the same set of tags, never more of them, and none left under the old name. - Notes integration tests: rename + undo, typing during a rename is kept, a big rename never stalls typing, and a crash mid-job. - The real binary is killed with SIGKILL 4× during a 400-note rename; each tag is renamed exactly once. - e2e (Chromium and WebKit, `tag-rename.spec.ts`): rename with the count and live editor/sidebar, Undo, a change from another device while typing the name, Escape, drag-to-merge with the bar question, and Escape in the bar. Perf numbers and screenshots are on #101. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
`notes.rename-tag {from, to}` renames a tag, and every tag nested under
it, in every note: one bulk-edit job (planned first, applied through each
note's room, resumable after a crash, undone as one step).
`notes.tag-preview {tag}` says how many notes carry it, from the note
list alone.

- tags/scan, tags/front: one reader of tags for the list, search and
  renames. Code blocks (also in quotes and lists), inline code, link
  targets, reference definitions, web addresses and <...> are never
  tags, so a rename never touches them.
- tags/rewrite: minimal edits (the name only), checked by property tests
  built from pieces with known answers, and by rename-and-back on any
  text.
- Bulk jobs save each batch's results in a small job_steps table instead
  of rewriting the whole plan, and answer notes.job from memory while
  they run (a 20k-step plan is several MB).
- A job that only reads a note lets its room close at once (Cmd::Rest),
  so planning thousands of notes keeps few rooms open.
- The request answers within 5 s; a bigger job goes on in the
  background and notes.job tells when it is done.
- Tests: rename + undo, typing during a rename, a big rename never
  stalls typing, a crash mid-job, and SIGKILL of the real binary again
  and again mid-rename (each tag renamed exactly once). Benchmark:
  examples/tag_bench on the perf fixture Homes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Rename… in a tag's menu (or a double-click on its row) opens the name in
place; while you type, the row says how many notes will change, where
its count was, so nothing moves. Enter renames it in every note on the
server; the row keeps its spot, an open list follows the new name, and
one toast offers Undo for every note at once. A long rename says it's
working first.

InlineRename fixes found on the way: it saves once (Enter, then the
field's blur on the way out, saved twice; Escape could still save), a
live change to the list behind it no longer resets what's being typed,
and the field is as wide as its text instead of scrolling the first
letter away. Double-click on a sidebar tag reaches the row again (the
row holds the pointer to drag, so the name never saw it).

e2e (Chromium and WebKit): rename with count and live editor/sidebar,
Undo across notes, a change from another device while typing the name,
and Escape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
The list renames the tag (and the tags under it) in its own entries at
once, so the row keeps its spot with its new name instead of vanishing
and coming back at the end until the server's changes arrive. A failed
rename, or Undo, puts the entries back. The count preview appears
without a fade (it follows typing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
A note that has both the old and the new tag keeps the new one once:
the renamed `#old` is removed with one space beside it, and in `tags:`
the item goes with its comma or its `- item` line. Nested tags follow
(`#old/x` becomes `#new/x`, or goes if `#new/x` is there). Repeats that
were there before stay, and a tag next to words (`(#old)`) is renamed,
never cut, so words are never joined. Property test: after a merge the
note has the same tags, never more of them, none under the old name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Web: drop a tag on another to merge them, after the bar asks (#101)
Some checks failed
CI / web (pull_request) Failing after 17s
CI / rust (pull_request) Successful in 2m2s
2637d81361
Dragging a tag row and holding it right over another tag's row lights
that row up; dropping there asks in the bar ("Merge #a into #b?
313 notes", Enter or Merge to go, Escape or Cancel to leave it), then
runs the rename onto the existing tag as one job, with Undo in the
toast ("Merged #a into #b in 313 notes"). A drop between rows still
just moves the row. On the phone the bar comes up over the places
screen only to ask.

Renaming onto an existing tag from the field is the same merge: the
merged row just goes (the other row is already in place) and Undo
brings it back where it was.

The new actions are in contracts/actions.json and actions.gen.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

Review:

  • Rename: rename and merge run as one bulk-edit job through the live rooms; it resumes after kill -9 and undoes in one step.
  • Merge: a merge leaves no duplicate tag, and tags in frontmatter are included.
  • Sidebar: drag-to-merge asks in the bar first.

Merged with dev: the jobs.rs doc comment clashed with #149; I kept both. After the merge, fmt passes, calternal and notes (164) pass, and svelte-check reports 0 errors.

Merging. Follow-up: set the perf budget proposed on #101 (about 10 s for 2k notes and 3 min for 20k on the perf VM, with typing p95 under 2× idle).

Review: - **Rename:** rename and merge run as one bulk-edit job through the live rooms; it resumes after kill -9 and undoes in one step. - **Merge:** a merge leaves no duplicate tag, and tags in frontmatter are included. - **Sidebar:** drag-to-merge asks in the bar first. **Merged with dev:** the jobs.rs doc comment clashed with #149; I kept both. After the merge, fmt passes, calternal and notes (164) pass, and svelte-check reports 0 errors. Merging. **Follow-up:** set the perf budget proposed on #101 (about 10 s for 2k notes and 3 min for 20k on the perf VM, with typing p95 under 2× idle).
kayg closed this pull request 2026-10-14 11:19:59 +00:00
Some checks failed
CI / web (pull_request) Failing after 17s
CI / rust (pull_request) Successful in 2m2s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!238
No description provided.