diff --git a/CLAUDE.md b/CLAUDE.md index 8859b5d..cf9182f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,11 +56,11 @@ These invariants are NOT SUGGESTIONS. These MUST be followed. **Locator flags are unified:** address an existing thing with **`--at LOCATOR`** everywhere (edit, insert, delete, tables, comments, footnotes/endnotes, images, hyperlinks, track-changes; `headers`/`footers` take `--at sN` to scope to one section, defaulting to the whole document). For `insert`, `--at` means AFTER an ordinary block and INTO a bare cell; `--before`/`--after` choose an explicit block side or a cell's start/end boundary, while `--at-start`/`--at-end` address document boundaries (no locator, single-shot only, not batchable). A bare-cell insert fills/reuses Word's mandatory empty paragraph and otherwise appends; bare-cell edit aliases exactly one direct paragraph. Both reject merged/grid-shifted cells, and edit rejects multi-block cells — use `CELL:pK` for precision. `read` uses `--from`/`--to` (a block slice), `comments add` also takes `--anchor PHRASE`, and `wc` takes a positional `[LOCATOR]`. No more `--id`/`--range`/`--to` for addressing. -**`--batch FILE.jsonl` (one JSONL object per line; `-` = stdin)** uses the shared `readJsonlObjects` reader (`cli/parse-helpers.ts`), with each command's path in a sibling `batch.ts`. `find --batch` is the read-only form: independent `{ query, … }` / formatting-filter entries all run against one immutable document read; text output flattens locators in entry order, while `--json` preserves request boundaries in a `batch` array. The mutation form lets `edit`, `insert`, `replace`, `delete`, and the `comments` verbs apply many changes from a single read — keys mirror the command's flags (`delete` entries are just `{ at }`). **All locators in a mutation batch address the document AS READ** — the load-bearing invariant — and each command keeps that true differently: `edit` resolves every entry to a live `XmlNode` ref _before any mutation_ (same-paragraph spans apply right-to-left; a paragraph takes one whole-paragraph edit OR several non-overlapping spans OR one line removal — empty `text` / `delete:true`, routed to the cell-safe `removeParagraphLine` so a form-fill fills cells AND drops leftover placeholder lines in one sweep); `replace` re-reads the live tree between entries (`document.reread()` re-walks `documentTree`, no re-parse, so node identity holds) for sed-like sequential semantics; `insert` pins block/cell anchors to live refs, builds all blocks with zero body mutation, reserves an initially empty cell's mandatory paragraph for its first bare-cell entry, rejects mixed bare-cell + explicit `CELL:pK` targeting in one batch, then splices with a per-anchor offset for block targets and one `CellInsertionCursor` (in `@core/table`) per cell, so stacked inserts keep entry order; `delete` resolves every locator to a live ref before any removal, then splices each by its live `indexOf` (so sibling shifts never misfire); `comments reply` validates every parent `cN` before minting any reply (reply ids append, so parents never shift), and an entry's `"resolve": true` resolves its thread root after all replies land — a finalize pass is `track-changes apply` + ONE `comments reply --batch`. Review work is where weak agents most often go one-call-per-change (the contract scenarios), so the `track-changes`/`comments` noun helps lead with the batched review recipe. Range/span `edit`s and `delete`s aren't batchable (do them one at a time); section (`sN`) and equation (`eqN`) targets aren't `edit`'s surface at all — use `docx sections` / `docx equations edit` (and `delete --at sN` for a section break). +**`--batch FILE.jsonl` (one JSONL object per line; `-` = stdin)** uses the shared `readJsonlObjects` reader (`cli/parse-helpers.ts`), with each command's path in a sibling `batch.ts`. `find --batch` is the read-only form: independent `{ query, … }` / formatting-filter entries all run against one immutable document read; text output flattens locators in entry order, while `--json` preserves request boundaries in a `batch` array. The mutation form lets `edit`, `insert`, `replace`, `delete`, `move`, and the `comments` verbs apply many changes from a single read — keys mirror the command's flags (`delete` entries are just `{ at }`). **All locators in a mutation batch address the document AS READ** — the load-bearing invariant — and each command keeps that true differently: `edit` resolves every entry to a live `XmlNode` ref _before any mutation_ (same-paragraph spans apply right-to-left; a paragraph takes one whole-paragraph edit OR several non-overlapping spans OR one line removal — empty `text` / `delete:true`, routed to the cell-safe `removeParagraphLine` so a form-fill fills cells AND drops leftover placeholder lines in one sweep); `replace` re-reads the live tree between entries (`document.reread()` re-walks `documentTree`, no re-parse, so node identity holds) for sed-like sequential semantics; `insert` pins block/cell anchors to live refs, builds all blocks with zero body mutation, reserves an initially empty cell's mandatory paragraph for its first bare-cell entry, rejects mixed bare-cell + explicit `CELL:pK` targeting in one batch, then splices with a per-anchor offset for block targets and one `CellInsertionCursor` (in `@core/table`) per cell, so stacked inserts keep entry order; `delete` resolves every locator to a live ref before any removal, then splices each by its live `indexOf` (so sibling shifts never misfire); `move` (the `Move` lens in `core/move`) resolves every source AND destination to live refs and runs every refusal before any splice, refuses overlapping sources and any anchor some entry moves, then detaches/splices by live `indexOf` with the same per-anchor offset as `insert` (one `CellInsertionCursor` per bare-cell destination); `comments reply` validates every parent `cN` before minting any reply (reply ids append, so parents never shift), and an entry's `"resolve": true` resolves its thread root after all replies land — a finalize pass is `track-changes apply` + ONE `comments reply --batch`. Review work is where weak agents most often go one-call-per-change (the contract scenarios), so the `track-changes`/`comments` noun helps lead with the batched review recipe. Range/span `edit`s and `delete`s aren't batchable (do them one at a time); section (`sN`) and equation (`eqN`) targets aren't `edit`'s surface at all — use `docx sections` / `docx equations edit` (and `delete --at sN` for a section break). **Output model — exit code is the success signal** (`0` ok, `1` general, `2` usage, `3` not-found, in `src/cli/respond.ts`), but every command also confirms in text — silence forced (weak) agents to re-read just to know a mutation took effect. The `ok` field appears ONLY on the `--verbose` ack. So: -- A mutator that mints a new addressable handle (`comments add`→`cN`, `comments reply`→`cN`, `footnotes/endnotes add`→`fnN`/`enN`, `hyperlinks add`→`linkN`, `insert`→the new `pN`) prints the bare locator(s) — one per line — by default (`respondMinted`); `--verbose` → full `{ok:true,…}` ack. +- A mutator that mints a new addressable handle (`comments add`→`cN`, `comments reply`→`cN`, `footnotes/endnotes add`→`fnN`/`enN`, `hyperlinks add`→`linkN`, `insert`→the new `pN`, `move`→the moved blocks' NEW locators) prints the bare locator(s) — one per line — by default (`respondMinted`); `--verbose` → full `{ok:true,…}` ack. - A mutator with no new handle (`create`/`edit`/`delete`/`replace`/`comments resolve`/`tables *`/`track-changes *`/`toggle`) prints a one-line, text-first confirmation on success — ` ` (e.g. `edit t1:r0c1:p0`, `edit 7 changes`, `replace 3 occurrences replaced`, `tables.insert-row t2 (1)`) via `respondAck`; `--verbose` → full `{ok,…}` JSON ack. The line is derived centrally in `respondAck`/`summarizeAck` from the ack payload's `operation` + most salient field (locator, count, id, table cell, …), so adding a mutator gets a confirmation for free as long as its ack carries `operation`. - Query commands are text-first where there's a clean plain form: `find`→locator lines, `wc`→count, `outline`→indented `locator␉text` tree, `read`→markdown — each with `--json`/`--ast` for the structured payload. List verbs print a bare JSON array whose items' `id` is the `--at` handle. - Errors print `{code, error, hint?}` (no `ok`) + a nonzero exit. Dry-run previews drop `ok` too. diff --git a/REFERENCE.md b/REFERENCE.md index 263489d..7457b7a 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -177,6 +177,7 @@ docx insert FILE (--after | --before) LOCATOR # explicit block side docx insert FILE (--at-start | --at-end) # no locator — prepend / append to the document docx edit FILE --at LOCATOR # LOCATOR = pN | pN:S-E | pN-pM | tN:rRcC | tN:rRcC:pK[:S-E]; bare CELL requires one direct paragraph docx delete FILE --at LOCATOR # LOCATOR = pN | pN-pM | tN | sN | tN:rRcC:pK (cell paragraph) +docx move FILE --at LOCATOR (--after | --before) LOCATOR [--subtree] # relocate blocks in place (see below) docx sections FILE [--at LOCATOR] [--columns N] [--type T] [--orientation O] [--size SIZE] [--margins M] # LOCATOR = pN-pM | pN (wrap a range in N columns) | sN (edit one section's columns/type/page geometry). Multi-column layout AND page setup live HERE. PAGE GEOMETRY (margins/orientation/size) with NO --at applies to the WHOLE document (every section); --at sN targets one. Columns/type need --at. docx styles set-default-font FILE "Font Name" [--size N] [--all] # document-wide font: sets styles.xml docDefaults + theme major/minor; --all also repoints styles/runs that pin their own font docx replace FILE PATTERN REPLACEMENT [--at pN] [--regex] [--ignore-case] [--all] [--limit N] [--current | --baseline] [--exact] [--clear ATTRS] [run-formatting] [--track] [--dry-run] @@ -206,6 +207,7 @@ docx edit FILE --batch fills.jsonl # { at, , style?, color?, … } docx replace FILE --batch script.jsonl # { pattern, replacement, at?, regex?, all?, limit?, clear?, bold?, color?, … } applied in order docx delete FILE --batch drop.jsonl # { at } per line — whole blocks (pN/tN/cell), resolved live-first +docx move FILE --batch moves.jsonl # { at, after|before, subtree? } — applied in order; several after one anchor keep entry order # All four of insert/edit/delete/replace accept --track to record that one # invocation as a tracked change even when the doc's track-changes toggle is off. @@ -545,6 +547,17 @@ docx edit doc.docx --at p3 --markdown-file revised.md # multi-block source exp A source starting with `-` (a bullet) works inline too — `docx insert doc.docx --after p3 "- item"`; `--markdown-file` is for long or multi-block sources. +**move a clause, section, or table.** `move` splices the blocks themselves — no copy, no Markdown round-trip — so comment ranges, footnote references, bookmarks, fields, images, text boxes and every paragraph property come along untouched. Don't read → `insert` a copy → `delete` the original: that orphans the comment and turns `[^fn1]` into literal text. + +```sh +docx move doc.docx --at p7 --before p2 # one paragraph +docx move doc.docx --at p4-p9 --after p20 # a range (tables between come too) +docx move doc.docx --at p12 --subtree --before p3 # a heading + everything under it (next same-or-higher heading ends it) +docx move doc.docx --at p5 --after t0:r1c2 # into a bare cell (fills an empty cell's blank paragraph) +``` + +`--at` takes `pN`, `pN-pM`, `tN`, a cell paragraph `tN:rRcC:pK`, or a text-box paragraph `tbxN:pK`; `--after`/`--before` take a block or a bare cell (end/start), exactly like `insert`. It prints the moved blocks' NEW locators. Refused with a nonzero exit and nothing written: a destination inside the source, a move that lands where the blocks already are, a source paragraph carrying a section break, a comment/bookmark range with only one end inside the source, a move between the body and a text box, and any move while track changes is on — tracked moves (``/``) aren't written yet. A moved list item keeps its own numbering (`numPr`), as in Word. A cell or text box left without a paragraph gets Word's mandatory empty one. + **track-changes review loop.** Toggle tracking on, make edits (they auto-emit ``/``), then inventory and resolve: ```sh diff --git a/skills/docx-cli/SKILL.md b/skills/docx-cli/SKILL.md index 0c21450..4516a6a 100644 --- a/skills/docx-cli/SKILL.md +++ b/skills/docx-cli/SKILL.md @@ -53,6 +53,7 @@ it if you use `--ast`. | leave notes on clauses | `comments add FILE --batch -` with lines like `{"anchor":"limitation of liability","text":"Cap is too low."}` | | finalize a returned redline | `track-changes list` → `track-changes apply FILE --accept rev0 rev2 --reject rev1` → `comments reply FILE --batch -` with `{"at":"c0","text":"Done.","resolve":true}` | | fill out a contract, NDA, form, or template | `replace FILE --batch -` with `{"pattern":"[Client]","replacement":"Acme, Inc."}` (keeps each field's formatting) | +| reorder / move a clause, section, or table | `move FILE --at p12 --before p3` (`--subtree` on a heading takes its whole section) — never re-insert a copy and delete the original | | restyle headings, fonts, spacing, margins | `styles set`, `styles set-default-font`, `sections --margins` | | write a new document | `create out.docx --from draft.md` | @@ -142,7 +143,7 @@ docx render FILE --out pages/ # PNG per page — only when LAYOUT is ## 4. Apply many changes from one read — `--batch` -`edit`, `insert`, `replace`, `delete`, and the `comments` verbs take +`edit`, `insert`, `replace`, `delete`, `move`, and the `comments` verbs take `--batch FILE.jsonl` (one JSON change per line; `-` reads stdin). Every locator in the batch addresses the document **as read**, so ids stay valid across the whole batch — one read, one write, no re-reading between changes. Keys mirror the @@ -155,7 +156,7 @@ command's flags. This is the right tool for filling a form or applying a review. have to re-read just to learn whether a mutation landed. - Mutators overwrite `FILE` **in place** (git is your history). `-o/--output PATH` writes a copy instead; `--dry-run` previews without writing. -- A command that mints a new handle (`comments add`→`cN`, `insert`→`pN`, +- A command that mints a new handle (`comments add`→`cN`, `insert`/`move`→`pN`, `footnotes add`→`fnN`, …) prints the bare locator(s), one per line. - Re-read after structural edits (ids shift), or batch from one read. - Need exact literal text in (a URL, prose GFM would mangle)? `insert` and diff --git a/skills/docx-cli/references/commands.md b/skills/docx-cli/references/commands.md index a7cc076..ada6935 100644 --- a/skills/docx-cli/references/commands.md +++ b/skills/docx-cli/references/commands.md @@ -32,6 +32,7 @@ docx info skill # this skill, regenerated from the binary | `edit FILE` | Replace/strip text or formatting at a locator; a bare simple cell fills its sole paragraph (`--clear`, `--track`, `--batch`). | | `insert FILE` | Insert content with `--at LOCATOR` (after a block, into a bare cell) or explicit `--before/--after` (`--track`, `--batch`). | | `delete FILE` | Remove a paragraph, range, table, or section break (`--at`, `--track`, `--batch`). | +| `move FILE` | Relocate blocks in place — comments/footnotes/formatting ride along (`--at`, `--after`/`--before`, `--subtree`, `--batch`; untracked only). | | `replace FILE PATTERN REPL` | Substitute spans; defaults to the first match (`--all` for every match), preserves formatting/tabs, and can format or clear formatting on the replacement (`--bold`, `--color`, `--clear`, `--batch`). | | `sections FILE` | Multi-column layout, section breaks, and page setup (margins/orientation/size). The only way to do columns. | | `styles set/create/set-default-font` | Restyle every heading at once, mint a style, or set the document font. | diff --git a/src/cli/CLAUDE.md b/src/cli/CLAUDE.md index 2eace70..8b8c51c 100644 --- a/src/cli/CLAUDE.md +++ b/src/cli/CLAUDE.md @@ -27,6 +27,24 @@ mix, and `positive` is checked AFTER rounding (`0.01pt` is a 0-twip value). A ne (`--widths 25%,…`, `--table-width 50%`) are handled by the caller before the length parse. Font `--size` and `--border-size` are not lengths — leave them. +## `move` — relocate, never re-author + +[move/](move/) is a thin parser over the `Move` lens in +[`core/move`](../core/move/index.ts): `plan()` resolves every source and +destination to LIVE `XmlNode` refs and runs every refusal against the document +as read (no mutation), `apply()` detaches each source and splices it at its +destination by live `indexOf`. Because the nodes themselves move, everything +inside them — comment ranges, note references, bookmarks, fields, drawings, +text boxes, `dcx:raw` blocks — rides along with no per-construct code; resist +adding any (a copy-then-delete path is exactly what issue #38 replaced). Every +refusal is a nonzero exit with nothing written, including a move that lands +where the blocks already are (no-op rule) and a range marker with one end inside +the source (comment/bookmark/perm/move ranges, `RANGE_PAIRS` in +`core/move/guards.ts`). **Untracked only for now**: `move` refuses while +tracking is on rather than silently skipping the journal — the tracked shape +(``/`` + range markers, paired by `w:name` in review) +must be captured from the Word oracle before it is written. + ## `raw` — the verbatim `--xml` channel `docx raw insert/replace` takes `--xml` VERBATIM — never route it through diff --git a/src/cli/help.ts b/src/cli/help.ts index 00da038..86811ed 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -29,6 +29,7 @@ Commands (each one-liner names capabilities you'd otherwise miss; see replace FILE PATTERN REPL Replace the FIRST match sed-style (--all for every match). KEEPS formatting/tabs and can format the replacement (--bold/--color/--clear; --track; --batch). edit FILE Replace content at a locator — pass the new markdown after the locator (the dialect \`read\` prints; --text is literal). --clear strips formatting, --track redlines, --batch does many edits in one read. ${NOUN_REDIRECTS} delete FILE Remove content at a locator (--at LOCATOR; --track for tracked deletion; --batch to remove many in one read) + move FILE Relocate blocks in place — "move FILE --at p7 --before p2"; pN-pM / tN / cell paragraphs; --subtree moves a heading with its section; comments, footnotes & formatting ride along (never re-insert a copy + delete); --batch. outline FILE List headings as a locator tree (pN feeds --at / read --from; --style-prefix, --json) wc FILE [LOCATOR] Count words in the doc or a slice (--accepted/--baseline/--current tracked view, --json) sections FILE Multi-column layout, section breaks & PAGE SETUP — columns on a range (--at pN-pM --columns N); page margins/orientation/size for the WHOLE document ("--margins 0.5" with NO --at sets every section) or one section (--at sN). The ONLY way to do columns; insert does not. diff --git a/src/cli/index.ts b/src/cli/index.ts index c9b2a65..1410ca3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,7 @@ const COMMANDS: Record Promise<{ run: CommandFn }>> = { info: () => import("./info"), insert: () => import("./insert"), lists: () => import("./lists"), + move: () => import("./move"), outline: () => import("./outline"), raw: () => import("./raw"), read: () => import("./read"), diff --git a/src/cli/info/skill.ts b/src/cli/info/skill.ts index fea51d4..243652d 100644 --- a/src/cli/info/skill.ts +++ b/src/cli/info/skill.ts @@ -106,6 +106,7 @@ it if you use \`--ast\`. | leave notes on clauses | \`comments add FILE --batch -\` with lines like \`{"anchor":"limitation of liability","text":"Cap is too low."}\` | | finalize a returned redline | \`track-changes list\` → \`track-changes apply FILE --accept rev0 rev2 --reject rev1\` → \`comments reply FILE --batch -\` with \`{"at":"c0","text":"Done.","resolve":true}\` | | fill out a contract, NDA, form, or template | \`replace FILE --batch -\` with \`{"pattern":"[Client]","replacement":"Acme, Inc."}\` (keeps each field's formatting) | +| reorder / move a clause, section, or table | \`move FILE --at p12 --before p3\` (\`--subtree\` on a heading takes its whole section) — never re-insert a copy and delete the original | | restyle headings, fonts, spacing, margins | \`styles set\`, \`styles set-default-font\`, \`sections --margins\` | | write a new document | \`create out.docx --from draft.md\` | @@ -195,7 +196,7 @@ docx render FILE --out pages/ # PNG per page — only when LAYOUT is ## 4. Apply many changes from one read — \`--batch\` -\`edit\`, \`insert\`, \`replace\`, \`delete\`, and the \`comments\` verbs take +\`edit\`, \`insert\`, \`replace\`, \`delete\`, \`move\`, and the \`comments\` verbs take \`--batch FILE.jsonl\` (one JSON change per line; \`-\` reads stdin). Every locator in the batch addresses the document **as read**, so ids stay valid across the whole batch — one read, one write, no re-reading between changes. Keys mirror the @@ -208,7 +209,7 @@ command's flags. This is the right tool for filling a form or applying a review. have to re-read just to learn whether a mutation landed. - Mutators overwrite \`FILE\` **in place** (git is your history). \`-o/--output PATH\` writes a copy instead; \`--dry-run\` previews without writing. -- A command that mints a new handle (\`comments add\`→\`cN\`, \`insert\`→\`pN\`, +- A command that mints a new handle (\`comments add\`→\`cN\`, \`insert\`/\`move\`→\`pN\`, \`footnotes add\`→\`fnN\`, …) prints the bare locator(s), one per line. - Re-read after structural edits (ids shift), or batch from one read. - Need exact literal text in (a URL, prose GFM would mangle)? \`insert\` and diff --git a/src/cli/insert/place.ts b/src/cli/insert/place.ts index 9d0f341..5012f3c 100644 --- a/src/cli/insert/place.ts +++ b/src/cli/insert/place.ts @@ -428,11 +428,7 @@ export function mintedLocatorsFor( document: Document, insertedNodes: Set, ): string[] { - const locators: string[] = []; - for (const [blockId, reference] of document.body.blockReferences) { - if (insertedNodes.has(reference.node)) locators.push(blockId); - } - return locators; + return document.body.locatorsOf(insertedNodes); } /** Inserts whose result depends on page layout — multi-column sections, page/ diff --git a/src/cli/move/batch.ts b/src/cli/move/batch.ts new file mode 100644 index 0000000..76800b9 --- /dev/null +++ b/src/cli/move/batch.ts @@ -0,0 +1,82 @@ +import type { MoveRequest } from "@core"; +import { readJsonlObjects } from "../parse-helpers"; +import { fail } from "../respond"; + +/** `docx move --batch FILE.jsonl`: one `{ at, after|before, subtree? }` per + * line. Only the SHAPE is checked here; the Move lens resolves every locator + * against the document as read before anything moves. */ +export async function readMoveBatch( + source: string, + values: Record, +): Promise { + const singleFlags = BATCH_KEYS.filter((flag) => values[flag] !== undefined); + if (singleFlags.length > 0) { + return fail( + "USAGE", + `--batch reads every move from the JSONL file; don't also pass --${singleFlags.join(", --")}`, + 'Put one {"at": LOCATOR, "after"|"before": LOCATOR} per line.', + ); + } + let entries: Record[]; + try { + entries = await readJsonlObjects(source); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return fail("USAGE", `Failed to read batch: ${message}`); + } + if (entries.length === 0) return fail("USAGE", "Batch file is empty"); + + const requests: MoveRequest[] = []; + for (const [index, entry] of entries.entries()) { + const { at, after, before, subtree } = entry; + const unknownKeys = Object.keys(entry).filter( + (key) => !BATCH_KEYS.includes(key), + ); + if (unknownKeys.length > 0) { + return fail( + "USAGE", + `entry ${index}: unknown key ${unknownKeys.map((key) => `"${key}"`).join(", ")}`, + 'Each line is {"at": LOCATOR, "after"|"before": LOCATOR, "subtree"?: true}.', + ); + } + if (typeof at !== "string" || at.length === 0) { + return fail("USAGE", `entry ${index}: "at" is required`); + } + if ( + (after !== undefined && typeof after !== "string") || + (before !== undefined && typeof before !== "string") || + (subtree !== undefined && typeof subtree !== "boolean") + ) { + return fail( + "USAGE", + `entry ${index}: "after"/"before" must be locator strings and "subtree" a boolean`, + ); + } + const destination = pickDestination(after, before); + if (typeof destination === "string") { + return fail( + "USAGE", + `entry ${index}: ${destination.replace(/--(after|before)/g, '"$1"')}`, + ); + } + requests.push({ at, ...destination, subtree: subtree ?? false }); + } + return requests; +} + +/** The keys a batch line may carry — the single-shot flags, one to one. */ +const BATCH_KEYS: readonly string[] = ["at", "after", "before", "subtree"]; + +/** Exactly one of `--after` / `--before`; returns the error message otherwise. + * Shared with the batch reader so both surfaces word it the same. */ +export function pickDestination( + after: string | undefined, + before: string | undefined, +): Pick | string { + if (after !== undefined && before !== undefined) { + return "Pass exactly one of --after or --before, not both"; + } + if (after) return { to: after, side: "after" }; + if (before) return { to: before, side: "before" }; + return "Missing destination: pass --after LOCATOR or --before LOCATOR"; +} diff --git a/src/cli/move/index.ts b/src/cli/move/index.ts new file mode 100644 index 0000000..b971c2b --- /dev/null +++ b/src/cli/move/index.ts @@ -0,0 +1,184 @@ +import { Move, MoveError, type MoveRequest, type PlannedMove } from "@core"; +import { batchExampleIntro } from "../parse-helpers"; +import { + EXIT, + fail, + openOrFail, + respond, + respondMinted, + SAVE_FLAGS, + STALE_LOCATOR_HINT, + setVerboseAck, + tryParseArgs, + writeStdout, +} from "../respond"; +import { pickDestination, readMoveBatch } from "./batch"; + +const HELP = `docx move — relocate existing blocks, untouched + +Moves the blocks themselves (no copy, no Markdown round-trip), so everything +in them comes along: formatting, comments, footnotes, bookmarks, fields, +images, text boxes. Prefer this over insert-a-copy + delete-the-original. + +Usage: + docx move FILE --at LOCATOR (--after | --before) LOCATOR [options] + docx move FILE --at pN --subtree (--after | --before) LOCATOR [options] + docx move FILE --batch FILE.jsonl [options] # many moves, one read (- = stdin) + +Examples: + docx move doc.docx --at p7 --before p2 # one paragraph up + docx move doc.docx --at p4-p9 --after p20 # a run of blocks + docx move doc.docx --at p12 --subtree --before p3 # a heading + its section + docx move doc.docx --at t1 --after p0 # a table + docx move doc.docx --at p5 --after t0:r1c2 # into a table cell (at its end) +${batchExampleIntro("Reorder several things")} + # moves.jsonl: + # {"at":"p3","after":"p9"} + # {"at":"p12","subtree":true,"before":"p0"} + docx move doc.docx --batch moves.jsonl + +What moves (--at): + pN a paragraph pN-pM paragraphs pN..pM and all between + tN a table tN:rRcC:pK / tbxN:pK a cell / text-box paragraph + --subtree with a heading pN: the heading plus everything under it, up to + the next heading of the same or higher level (see \`docx outline\`) + +Where it goes (exactly one): + --after LOCATOR after a block, or at the END of a bare cell (tN:rRcC) + --before LOCATOR before a block, or at the START of a bare cell + +Refused (nonzero exit, nothing written): a destination inside the moved +blocks; a move that lands where the blocks already are; a paragraph carrying a +section break; a comment or bookmark with one end inside the moved blocks and +one outside; a move between the body and a text box; any move while track +changes is on (tracked moves aren't supported yet). + +Batch (--batch PATH | -): one {"at", "after"|"before", "subtree"?} per line. + Every locator addresses the document AS READ; moves apply in file order, and + several moved after the SAME block land in that order. Entries may not move + overlapping blocks or anchor on a block another entry moves. + +Options: + -o, --output PATH Write to PATH instead of overwriting FILE + --dry-run Check the move(s); write nothing + -v, --verbose Full JSON ack + -h, --help Show this help + +Output: the moved blocks' NEW locators, one per line (exit 0) — the old ones +are stale after a move. Errors print {code, error, hint?} with a nonzero exit. +`; + +const OPTION_SPEC = { + at: { type: "string" }, + after: { type: "string" }, + before: { type: "string" }, + subtree: { type: "boolean" }, + batch: { type: "string" }, + ...SAVE_FLAGS, +} as const; + +export async function run(args: string[]): Promise { + const parsed = await tryParseArgs(args, OPTION_SPEC, HELP); + if (typeof parsed === "number") return parsed; + if (parsed.values.help) { + await writeStdout(HELP); + return EXIT.OK; + } + setVerboseAck(Boolean(parsed.values.verbose)); + + const [filePath, extra] = parsed.positionals; + if (!filePath) return fail("USAGE", "Missing FILE argument", HELP); + if (extra !== undefined) { + return fail( + "USAGE", + `Unexpected argument: ${extra}`, + "move takes no content — it relocates existing blocks. Pass --at LOCATOR and --after/--before LOCATOR.", + ); + } + + const batchSource = parsed.values.batch as string | undefined; + const requests = + batchSource !== undefined + ? await readMoveBatch(batchSource, parsed.values) + : await parseSingleRequest(parsed.values); + if (typeof requests === "number") return requests; + + return commitMoves(filePath, requests, { + outputPath: parsed.values.output as string | undefined, + dryRun: Boolean(parsed.values["dry-run"]), + }); +} + +async function parseSingleRequest( + values: Record, +): Promise { + const at = values.at as string | undefined; + if (!at) return fail("USAGE", "Missing --at LOCATOR (or --batch FILE)", HELP); + const destination = pickDestination( + values.after as string | undefined, + values.before as string | undefined, + ); + if (typeof destination === "string") { + return fail("USAGE", destination, HELP); + } + return [{ at, ...destination, subtree: Boolean(values.subtree) }]; +} + +async function commitMoves( + filePath: string, + requests: MoveRequest[], + opts: { outputPath?: string; dryRun: boolean }, +): Promise { + const document = await openOrFail(filePath); + if (typeof document === "number") return document; + + if (document.isTrackChangesEnabled()) { + return fail( + "UNSUPPORTED", + "Track changes is on, and tracked moves aren't supported yet — nothing was moved", + "To record it as a tracked change, insert the content at the destination and delete the original (both redline). To move it untracked, run `docx track-changes off`, move, then `docx track-changes on`.", + ); + } + + const lens = new Move(document); + let planned: PlannedMove[]; + try { + planned = lens.plan(requests); + } catch (error) { + if (!(error instanceof MoveError)) throw error; + const prefix = error.entry !== undefined ? `entry ${error.entry}: ` : ""; + const hint = + error.hint ?? + (error.code === "BLOCK_NOT_FOUND" ? STALE_LOCATOR_HINT : undefined); + return fail(error.code, `${prefix}${error.message}`, hint); + } + + const moves = requests.map(({ at, side, to, subtree }) => ({ + at, + [side]: to, + ...(subtree ? { subtree: true } : {}), + })); + if (opts.dryRun) { + await respond({ + operation: "move", + dryRun: true, + path: filePath, + moves, + ...(opts.outputPath ? { output: opts.outputPath } : {}), + }); + return EXIT.OK; + } + + const placed = lens.apply(planned); + await document.save(opts.outputPath); + document.reread(); + const locators = document.body.locatorsOf(new Set(placed)); + await respondMinted(locators, { + ok: true, + operation: "move", + path: opts.outputPath ?? filePath, + locators, + moves, + }); + return EXIT.OK; +} diff --git a/src/cli/outline/build.ts b/src/cli/outline/build.ts index f8b09ca..fa4edb8 100644 --- a/src/cli/outline/build.ts +++ b/src/cli/outline/build.ts @@ -1,4 +1,10 @@ -import { type Block, type Body, type Paragraph, paragraphText } from "@core"; +import { + type Block, + type Body, + headingLevel, + type Paragraph, + paragraphText, +} from "@core"; export type OutlineEntry = { id: string; @@ -48,19 +54,6 @@ export function buildOutline( return root.children; } -function headingLevel( - style: string | undefined, - stylePrefix: string, -): number | null { - if (!style) return null; - if (!style.startsWith(stylePrefix)) return null; - const remainder = style.slice(stylePrefix.length).trim(); - if (remainder === "") return 1; - const parsed = Number(remainder); - if (!Number.isInteger(parsed) || parsed < 1) return null; - return parsed; -} - function* headingParagraphs( blocks: Block[], stylePrefix: string, diff --git a/src/cli/respond.ts b/src/cli/respond.ts index a08f7a7..b3318e5 100644 --- a/src/cli/respond.ts +++ b/src/cli/respond.ts @@ -349,7 +349,7 @@ export function resolveTracked( * belongs on the error, not only in `--help` (which they act before reading). Worded * to match the codebase's standing line — ids shift after STRUCTURAL edits, NOT the * in-place content/format edit that leaves the edited locator unchanged. */ -const STALE_LOCATOR_HINT = +export const STALE_LOCATOR_HINT = "Locator ids are positional and shift after structural edits (insert/delete/section changes) — an id from an earlier read may now point elsewhere or be gone. Re-read the file to get current ids, then retry. Applying several changes? Do them from ONE read with --batch (edit/insert/delete/replace/comments) so ids never go stale mid-run."; export async function resolveBlockOrFail( diff --git a/src/core/ast/document/body.ts b/src/core/ast/document/body.ts index 0e238e8..47cfb8b 100644 --- a/src/core/ast/document/body.ts +++ b/src/core/ast/document/body.ts @@ -134,6 +134,17 @@ export class Body { return { parent: start.parent, startIndex, endIndex }; } + /** The current locators of `nodes`, in document order — how a structural + * mutator (insert, move) reports where its blocks landed. Call after + * `document.reread()`: positional ids shift on every structural edit. */ + locatorsOf(nodes: ReadonlySet): string[] { + const locators: string[] = []; + for (const [blockId, reference] of this.blockReferences) { + if (nodes.has(reference.node)) locators.push(blockId); + } + return locators; + } + findBodyChildren(): XmlNode[] { return this.body.children; } diff --git a/src/core/heading-level.ts b/src/core/heading-level.ts new file mode 100644 index 0000000..6e6710a --- /dev/null +++ b/src/core/heading-level.ts @@ -0,0 +1,16 @@ +/** The outline level a paragraph style id names — `Heading1` → 1, `Heading 2` + * → 2, a bare `Heading` → 1 — or null when the style isn't a heading. The one + * rule `outline` builds its tree from and `move --subtree` expands a heading's + * section by, so the two can't disagree about where a section ends. */ +export function headingLevel( + style: string | undefined, + stylePrefix = "Heading", +): number | null { + if (!style) return null; + if (!style.startsWith(stylePrefix)) return null; + const remainder = style.slice(stylePrefix.length).trim(); + if (remainder === "") return 1; + const parsed = Number(remainder); + if (!Number.isInteger(parsed) || parsed < 1) return null; + return parsed; +} diff --git a/src/core/index.ts b/src/core/index.ts index 46d8b4b..16b518d 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -67,6 +67,7 @@ export { resolveClearTags, } from "./edit"; export { Fonts, type SetDefaultFontResult } from "./fonts"; +export { headingLevel } from "./heading-level"; export { Insert, InsertError, @@ -121,6 +122,12 @@ export { MarkdownImportError, type MarkdownImportErrorCode, } from "./markdown"; +export { + Move, + MoveError, + type MoveRequest, + type PlannedMove, +} from "./move"; export { XmlNode } from "./parser"; export { detectEngine, diff --git a/src/core/move/destination.ts b/src/core/move/destination.ts new file mode 100644 index 0000000..3101ac8 --- /dev/null +++ b/src/core/move/destination.ts @@ -0,0 +1,68 @@ +import type { Document } from "../ast/document"; +import { + type CellReference, + parseCellAt, + resolveCellReference, +} from "../locators/resolve"; +import type { XmlNode } from "../parser"; +import { reusableEmptyCellParagraph } from "../table"; +import { MoveError } from "./move-error"; +import { storyOf } from "./source"; + +/** Where the moved blocks land — the same two shapes `insert --before/--after` + * takes: beside a block (`pN`, `tN`, a cell or text-box paragraph), or at a + * bare cell's start (`before`) / end (`after`). */ +export type MoveDestination = + | { + kind: "block"; + locator: string; + side: "before" | "after"; + anchor: XmlNode; + parent: XmlNode[]; + story: string; + } + | { + kind: "cell"; + locator: string; + side: "before" | "after"; + cell: CellReference; + /** Word's mandatory empty paragraph, when that's all the cell holds — + * the first move in fills it instead of stacking beside it. */ + reusableParagraph: XmlNode | null; + story: string; + }; + +export function resolveMoveDestination( + document: Document, + locator: string, + side: "before" | "after", +): MoveDestination { + const story = storyOf(locator); + if (parseCellAt(locator)) { + const cell = resolveCellReference(document, locator); + return { + kind: "cell", + locator, + side, + cell, + reusableParagraph: reusableEmptyCellParagraph(cell.node), + story, + }; + } + const reference = document.body.resolveBlock(locator); + if (reference.node.tag !== "w:p" && reference.node.tag !== "w:tbl") { + throw new MoveError( + "INVALID_LOCATOR", + `${locator} can't anchor a move — use the paragraph or table beside it`, + "Anchor on a paragraph or table (pN, tN, tN:rRcC:pK, tbxN:pK) or a bare cell (tN:rRcC). For the very end of the document, use --after on its last block.", + ); + } + return { + kind: "block", + locator, + side, + anchor: reference.node, + parent: reference.parent, + story, + }; +} diff --git a/src/core/move/guards.ts b/src/core/move/guards.ts new file mode 100644 index 0000000..de02de3 --- /dev/null +++ b/src/core/move/guards.ts @@ -0,0 +1,210 @@ +import type { XmlNode } from "../parser"; +import type { MoveDestination } from "./destination"; +import { MoveError } from "./move-error"; +import type { MoveSource } from "./source"; + +/** Refuse a source whose move would change more than its position: a + * paragraph carrying a section break would drag the section boundary along, + * and a range marker whose other end sits outside the source would be split + * or inverted by the move. */ +export function assertMovableSource(source: MoveSource): void { + const carriesSectPr = (node: XmlNode) => + node.tag === "w:sectPr" || node.findDescendant("w:sectPr") !== undefined; + if (source.nodes.some(carriesSectPr)) { + throw new MoveError( + "UNSUPPORTED", + `${source.locator} ends a section (it carries a section break), so moving it would move the section boundary`, + "Move the blocks around it instead, or change the layout with `docx sections`.", + ); + } + const straddler = straddlingRange(source.nodes); + if (straddler) { + throw new MoveError( + "UNSUPPORTED", + `${straddler.what} starts on one side of the edge of ${source.locator} and ends on the other — moving it would split that range`, + `Widen --at to a range (pN-pM) that holds both ends, or ${straddler.cure}`, + ); + } +} + +/** Moves stay inside one story: the body (cells included) or one text box. + * A text box's story is anchored by a body paragraph and mirrored into its + * Fallback twin, so carrying blocks across that boundary is out of scope. */ +export function assertSameStory( + source: MoveSource, + destination: MoveDestination, +): void { + if (source.story === destination.story) return; + throw new MoveError( + "UNSUPPORTED", + `Can't move ${source.locator} (${describeStory(source.story)}) to ${destination.locator} (${describeStory(destination.story)})`, + "move works within the body (table cells included) or within one text box. Across that boundary, insert the content at the destination and delete the original.", + ); +} + +export function assertDestinationOutsideSource( + source: MoveSource, + destination: MoveDestination, +): void { + if (!destinationInside(destination, source)) return; + throw new MoveError( + "USAGE", + `${destination.locator} is part of what's being moved (${source.locator}), so it can't be the destination`, + "Anchor on a block outside the blocks being moved.", + ); +} + +/** A move that lands the source where it already is changes nothing — an + * error, never a silent success. Judged on block siblings (paragraphs, + * tables, content controls), the order `read` shows. */ +export function assertNotNoOp( + source: MoveSource, + destination: MoveDestination, +): void { + const sameContainer = + destination.kind === "cell" + ? destination.cell.parent === source.parent + : destination.parent === source.parent; + if (!sameContainer) return; + const blocks = source.parent.filter(isBlock); + const sourceBlocks = source.nodes.filter(isBlock); + const first = blocks.indexOf(sourceBlocks[0] as XmlNode); + const last = blocks.indexOf(sourceBlocks[sourceBlocks.length - 1] as XmlNode); + const lands = + destination.kind === "cell" + ? destination.side === "before" + ? first === 0 + : last === blocks.length - 1 + : destination.side === "after" + ? blocks.indexOf(destination.anchor) === first - 1 + : blocks.indexOf(destination.anchor) === last + 1; + if (!lands) return; + throw new MoveError( + "USAGE", + `${source.locator} is already ${destination.side} ${destination.locator} — nothing to move`, + "Pick a destination elsewhere; `docx read` shows the current order.", + ); +} + +/** Batch: two entries may not move the same block, or a block inside another + * entry's block. */ +export function assertSourcesDisjoint( + earlier: MoveSource, + later: MoveSource, +): void { + const overlaps = earlier.nodes.some((outer) => + later.nodes.some( + (inner) => containsNode(outer, inner) || containsNode(inner, outer), + ), + ); + if (!overlaps) return; + throw new MoveError( + "USAGE", + `${later.locator} overlaps ${earlier.locator}, which an earlier entry already moves`, + "Each entry must move distinct blocks; merge the two into one entry.", + ); +} + +/** Whether the destination's anchor (a block, or a bare cell's ``) is + * one of the source nodes or sits inside one. */ +export function destinationInside( + destination: MoveDestination, + source: MoveSource, +): boolean { + const anchor = + destination.kind === "cell" ? destination.cell.node : destination.anchor; + return source.nodes.some((node) => containsNode(node, anchor)); +} + +function containsNode(root: XmlNode, target: XmlNode): boolean { + if (root === target) return true; + return root.children.some((child) => containsNode(child, target)); +} + +const BLOCK_TAGS = new Set(["w:p", "w:tbl", "w:sdt"]); + +function isBlock(node: XmlNode): boolean { + return BLOCK_TAGS.has(node.tag); +} + +/** Paired range markers keyed by `w:id`: a pair with exactly one end inside + * the source straddles its edge. */ +const RANGE_PAIRS = [ + { + start: "w:commentRangeStart", + end: "w:commentRangeEnd", + what: (id: string) => `Comment c${id}`, + cure: "re-anchor the comment first (`docx comments delete` + `comments add`).", + }, + { + start: "w:bookmarkStart", + end: "w:bookmarkEnd", + what: (id: string) => `Bookmark ${id}`, + cure: "move a range that keeps the bookmark whole.", + }, + { + start: "w:moveFromRangeStart", + end: "w:moveFromRangeEnd", + what: (id: string) => `Tracked move range ${id}`, + cure: "resolve the pending move first (`docx track-changes list`).", + }, + { + start: "w:moveToRangeStart", + end: "w:moveToRangeEnd", + what: (id: string) => `Tracked move range ${id}`, + cure: "resolve the pending move first (`docx track-changes list`).", + }, + { + start: "w:permStart", + end: "w:permEnd", + what: (id: string) => `Editing-permission range ${id}`, + cure: "move a range that keeps the permission range whole.", + }, +] as const; + +function straddlingRange( + nodes: XmlNode[], +): { what: string; cure: string } | null { + for (const pair of RANGE_PAIRS) { + const starts = new Map(); + const ends = new Map(); + for (const node of nodes) { + collectIds(node, pair.start, starts); + collectIds(node, pair.end, ends); + } + for (const [id, marker] of starts) { + if (ends.has(id)) continue; + return { what: label(pair, id, marker), cure: pair.cure }; + } + for (const id of ends.keys()) { + if (starts.has(id)) continue; + return { what: pair.what(id), cure: pair.cure }; + } + } + return null; +} + +function label( + pair: (typeof RANGE_PAIRS)[number], + id: string, + marker: XmlNode, +): string { + const name = marker.getAttribute("w:name"); + return name && pair.start === "w:bookmarkStart" + ? `Bookmark "${name}"` + : pair.what(id); +} + +function collectIds( + node: XmlNode, + tag: string, + into: Map, +): void { + const id = node.tag === tag ? node.getAttribute("w:id") : undefined; + if (id !== undefined) into.set(id, node); + for (const child of node.children) collectIds(child, tag, into); +} + +function describeStory(story: string): string { + return story === "body" ? "the body" : `text box ${story}`; +} diff --git a/src/core/move/index.ts b/src/core/move/index.ts new file mode 100644 index 0000000..b2c4c9d --- /dev/null +++ b/src/core/move/index.ts @@ -0,0 +1,207 @@ +import type { Document } from "../ast/document"; +import { LocatorResolveError } from "../ast/document/body"; +import { CellTargetError } from "../locators/cell-target-error"; +import { LocatorParseError } from "../locators/parse"; +import type { XmlNode } from "../parser"; +import { CellInsertionCursor, ensureCellEndsWithParagraph } from "../table"; +import { type MoveDestination, resolveMoveDestination } from "./destination"; +import { + assertDestinationOutsideSource, + assertMovableSource, + assertNotNoOp, + assertSameStory, + assertSourcesDisjoint, + destinationInside, +} from "./guards"; +import { MoveError } from "./move-error"; +import { type MoveSource, resolveMoveSource } from "./source"; + +export { MoveError }; + +/** Cross-cutting lens over "relocate existing blocks." Stateless — a + * back-reference to the document only. The move is a pure splice of the LIVE + * source nodes, so everything inside them (comment ranges, note references, + * bookmarks, fields, content controls, drawings, text boxes, `dcx:raw` + * blocks) rides along untouched — nothing round-trips through Markdown. + * + * Two phases so the CLI can preview: {@link plan} resolves and validates every + * request against the document AS READ (it mutates nothing), {@link apply} + * performs the splices in request order. Untracked only — the tracked shape + * (``/``) isn't written yet, so the CLI refuses a move + * while tracking is on. */ +export class Move { + constructor(private readonly document: Document) {} + + /** Resolve every request's source and destination to live node refs, then + * run every refusal — before anything moves. Throws a {@link MoveError} + * (with `entry` set when there is more than one request). */ + plan(requests: MoveRequest[]): PlannedMove[] { + const planned = requests.map((request, entry) => + withEntry(requests.length > 1 ? entry : undefined, () => + this.planOne(request), + ), + ); + if (planned.length > 1) assertBatchConsistent(planned); + return planned; + } + + /** Splice every planned move in order and return the nodes that now carry + * the moved content (a moved paragraph that filled an empty cell's + * mandatory paragraph is reported as that paragraph). */ + apply(planned: PlannedMove[]): XmlNode[] { + const placed: XmlNode[] = []; + const cursors = new Map(); + const lastPlacedAfter = new Map(); + const touchedContainers = new Set(); + for (const { source, destination } of planned) { + detach(source); + touchedContainers.add(source.parent); + if (destination.kind === "cell") { + placed.push(...placeInCell(destination, source, cursors)); + continue; + } + placeBesideBlock(destination, source.nodes, lastPlacedAfter); + touchedContainers.add(destination.parent); + placed.push(...source.nodes); + } + for (const cursor of cursors.values()) cursor.ensureTerminalParagraph(); + for (const container of touchedContainers) { + this.keepContainerValid(container); + } + return placed; + } + + private planOne(request: MoveRequest): PlannedMove { + const source = resolveMoveSource( + this.document, + request.at, + request.subtree ?? false, + ); + assertMovableSource(source); + const destination = resolveMoveDestination( + this.document, + request.to, + request.side, + ); + assertSameStory(source, destination); + assertDestinationOutsideSource(source, destination); + assertNotNoOp(source, destination); + return { source, destination }; + } + + /** A cell or text-box story must still hold a paragraph (and a cell must + * END with one) after its blocks moved out — Word rejects a bare + * ``. The body is exempt: a move never takes content out of it + * wholesale, and its trailing `` is never moved. */ + private keepContainerValid(container: XmlNode[]): void { + if (container === this.document.body.body.children) return; + ensureCellEndsWithParagraph(container); + } +} + +/** One relocation: the block(s) at `at` go `side` the block (or bare cell) at + * `to`. `subtree` widens a heading `at` to the heading plus everything under + * it, up to the next heading of the same or higher level. */ +export type MoveRequest = { + at: string; + to: string; + side: "before" | "after"; + subtree?: boolean; +}; + +export type PlannedMove = { source: MoveSource; destination: MoveDestination }; + +/** Run `body`, translating every locator/cell error it raises into a + * {@link MoveError} stamped with the batch entry index. */ +function withEntry(entry: number | undefined, body: () => T): T { + try { + return body(); + } catch (error) { + if (error instanceof MoveError) { + throw new MoveError(error.code, error.message, error.hint, entry); + } + if (error instanceof CellTargetError) { + throw new MoveError(error.code, error.message, error.hint, entry); + } + if (error instanceof LocatorParseError) { + throw new MoveError("INVALID_LOCATOR", error.message, undefined, entry); + } + if (error instanceof LocatorResolveError) { + throw new MoveError("BLOCK_NOT_FOUND", error.message, undefined, entry); + } + throw error; + } +} + +/** Batch-only refusals: every locator addresses the document AS READ, so two + * entries may not claim the same block, and no entry may anchor on a block + * some entry is moving (where it lands would depend on entry order). */ +function assertBatchConsistent(planned: PlannedMove[]): void { + for (const [entry, move] of planned.entries()) { + withEntry(entry, () => { + for (const earlier of planned.slice(0, entry)) { + assertSourcesDisjoint(earlier.source, move.source); + } + for (const [owner, other] of planned.entries()) { + if (!destinationInside(move.destination, other.source)) continue; + throw new MoveError( + "USAGE", + `${move.destination.locator} is moved by entry ${owner} (${other.source.locator}), so it can't also anchor a move`, + "Anchor on a block no entry moves, or fold adjacent moves into one range (pN-pM).", + ); + } + }); + } +} + +/** Take the source's nodes out of their parent by live `indexOf` (earlier + * moves may have shifted their siblings; the node refs stay valid). */ +function detach(source: MoveSource): void { + for (const node of source.nodes) { + const index = source.parent.indexOf(node); + if (index !== -1) source.parent.splice(index, 1); + } +} + +/** Splice beside a block anchor by its live index. Several moves anchored + * AFTER the same block stack in entry order (`lastPlacedAfter` is the + * per-anchor offset `insert --batch` uses); BEFORE stacks naturally. */ +function placeBesideBlock( + destination: Extract, + nodes: XmlNode[], + lastPlacedAfter: Map, +): void { + const { anchor, parent, side } = destination; + if (side === "before") { + parent.splice(parent.indexOf(anchor), 0, ...nodes); + return; + } + const previous = lastPlacedAfter.get(anchor); + const after = + previous && parent.includes(previous) ? previous : (anchor as XmlNode); + parent.splice(parent.indexOf(after) + 1, 0, ...nodes); + const last = nodes[nodes.length - 1]; + if (last) lastPlacedAfter.set(anchor, last); +} + +/** Splice at a bare cell's start/end through one cursor per cell, so stacked + * moves keep entry order. The first move into a cell that held only Word's + * mandatory empty paragraph fills that paragraph rather than leaving a blank + * line above (or below) the moved content. */ +function placeInCell( + destination: Extract, + source: MoveSource, + cursors: Map, +): XmlNode[] { + const { cell } = destination; + let cursor = cursors.get(cell.node); + // An earlier entry may have moved that empty paragraph out; reuse only a + // paragraph still in the cell. + const candidate = cursor ? null : destination.reusableParagraph; + const reuse = candidate && cell.parent.includes(candidate) ? candidate : null; + if (!cursor) { + cursor = CellInsertionCursor.open(cell); + cursors.set(cell.node, cursor); + } + return cursor.insert(source.nodes, destination.side, reuse); +} diff --git a/src/core/move/move-error.ts b/src/core/move/move-error.ts new file mode 100644 index 0000000..c31d9d9 --- /dev/null +++ b/src/core/move/move-error.ts @@ -0,0 +1,20 @@ +/** Why a move was refused. `code` is a subset of the CLI's error codes so the + * CLI can pass it straight through; `entry` names the batch line. */ +export class MoveError extends Error { + constructor( + public code: MoveErrorCode, + message: string, + public hint?: string, + public entry?: number, + ) { + super(message); + this.name = "MoveError"; + } +} + +export type MoveErrorCode = + | "USAGE" + | "INVALID_LOCATOR" + | "BLOCK_NOT_FOUND" + | "TABLE_STRUCTURE" + | "UNSUPPORTED"; diff --git a/src/core/move/source.ts b/src/core/move/source.ts new file mode 100644 index 0000000..ff8125b --- /dev/null +++ b/src/core/move/source.ts @@ -0,0 +1,115 @@ +import type { Document } from "../ast/document"; +import { headingLevel } from "../heading-level"; +import { parseLocator } from "../locators/parse"; +import { parseCellAt } from "../locators/resolve"; +import type { XmlNode } from "../parser"; +import { MoveError } from "./move-error"; + +/** The contiguous run of sibling nodes a move lifts out: `nodes` live in + * `parent` (a body, cell or text-box story child list) in document order. + * `story` is the text-box story the source lives in (`tbxN`), or `body`. */ +export type MoveSource = { + locator: string; + parent: XmlNode[]; + nodes: XmlNode[]; + story: string; +}; + +/** Resolve `--at` to the live nodes it names: a paragraph or table (`pN`, + * `tN`, a cell paragraph `tN:rRcC:pK`, a text-box paragraph `tbxN:pK`), a + * sibling range `pN-pM` (every node between the endpoints, tables included), + * or — with `subtree` — a heading plus the blocks under it. */ +export function resolveMoveSource( + document: Document, + locator: string, + subtree: boolean, +): MoveSource { + const parsed = parseLocator(locator); + if (parsed.kind === "blockRange") { + if (subtree) { + throw new MoveError( + "USAGE", + "--subtree expands a single heading; it doesn't combine with a range", + "Pass the heading's pN alone (`--at pN --subtree`), or drop --subtree.", + ); + } + const range = document.body.resolveBlockRange( + parsed.startBlockId, + parsed.endBlockId, + ); + return { + locator, + parent: range.parent, + nodes: range.parent.slice(range.startIndex, range.endIndex + 1), + story: storyOf(locator), + }; + } + if (/:\d+-\d+$/.test(locator) || parseCellAt(locator)) { + throw new MoveError( + "INVALID_LOCATOR", + `move relocates whole blocks; ${locator} isn't one`, + "Pass a paragraph or table locator (pN, pN-pM, tN, tN:rRcC:pK, tbxN:pK) — for a cell, its paragraph (tN:rRcC:pK). To move part of a paragraph's text, edit it out and insert it at the destination.", + ); + } + const reference = document.body.resolveBlock(locator); + if (reference.node.tag === "w:sectPr") { + throw new MoveError( + "UNSUPPORTED", + `${locator} is a section break, which can't be moved`, + "Section layout changes with `docx sections`; move the content around it instead.", + ); + } + const nodes = subtree + ? headingSubtree(reference.node, reference.parent, locator) + : [reference.node]; + return { + locator, + parent: reference.parent, + nodes, + story: storyOf(locator), + }; +} + +/** The text-box story a locator lives in (`tbx2:p0` → `tbx2`), else `body`. + * Cells don't start a story: moving between the body and a cell is fine. */ +export function storyOf(locator: string): string { + return /^(tbx\d+):/.exec(locator)?.[1] ?? "body"; +} + +/** A heading plus every following sibling up to (not including) the next + * heading of the same or higher level — the section `outline` nests under + * it, and what Word's Navigation pane drags. Stops before the body's + * trailing ``. */ +function headingSubtree( + heading: XmlNode, + parent: XmlNode[], + locator: string, +): XmlNode[] { + const level = paragraphHeadingLevel(heading); + if (level === null) { + throw new MoveError( + "USAGE", + `--subtree needs a heading, but ${locator} isn't one (no Heading style)`, + "Run `docx outline FILE` for the heading locators, or move a range with `--at pN-pM`.", + ); + } + const start = parent.indexOf(heading); + const nodes: XmlNode[] = [heading]; + for (let index = start + 1; index < parent.length; index++) { + const sibling = parent[index] as XmlNode; + if (sibling.tag === "w:sectPr") break; + const siblingLevel = paragraphHeadingLevel(sibling); + if (siblingLevel !== null && siblingLevel <= level) break; + nodes.push(sibling); + } + return nodes; +} + +function paragraphHeadingLevel(node: XmlNode): number | null { + if (node.tag !== "w:p") return null; + const style = node + .findChild("w:pPr") + ?.findChild("w:pStyle") + ?.getAttribute("w:val"); + return headingLevel(style); +} diff --git a/tests/cli/move.test.ts b/tests/cli/move.test.ts new file mode 100644 index 0000000..b85e8df --- /dev/null +++ b/tests/cli/move.test.ts @@ -0,0 +1,511 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { runCli, tempWorkspace } from "./harness"; +import { + buildRawDoc, + expectValidClean, + readDocumentXml, + readMarkdown, + storyXml, + wordTextBoxParagraphXml, +} from "./helpers"; + +/** `docx move` (issue #38): an untracked splice of the LIVE blocks, so + * everything inside them rides along — the read → insert-a-copy → delete + * workaround orphaned the comment and turned the footnote into literal + * `[^fn1]` text. */ + +const CLAUSES = `# Terms + +1. Payment due in 30 days. +2. Late fees apply. + +Clause B: Governing law is Delaware. + +Clause C: Notices go to the address above. +`; + +async function createDoc(label: string, markdown: string): Promise { + const workspace = tempWorkspace(label); + const source = join(workspace, "source.md"); + const docPath = join(workspace, "doc.docx"); + await Bun.write(source, markdown); + const created = await runCli("create", docPath, "--from", source); + expect(created.exitCode).toBe(0); + return docPath; +} + +/** The text `read` shows, locator comments and blank lines stripped. */ +async function readLines(docPath: string): Promise { + return (await readMarkdown(docPath)) + .split("\n") + .filter((line) => line.trim() !== "" && !line.startsWith("/g, "")); +} + +function paragraphContaining(xml: string, text: string): string { + const match = [...xml.matchAll(/][\s\S]*?<\/w:p>/g)].find((entry) => + entry[0].includes(text), + ); + if (!match) throw new Error(`no paragraph containing ${text}`); + return match[0]; +} + +describe("move — the issue #38 repro", () => { + test("comment, footnote and run formatting ride along; new locator printed", async () => { + const docPath = await createDoc("move-repro", CLAUSES); + await runCli( + "edit", + docPath, + "--at", + "p3:0-8", + "--text", + "Clause B", + "--bold", + "--color", + "C00000", + ); + await runCli( + "comments", + "add", + docPath, + "--anchor", + "Delaware", + "--text", + "Confirm with legal", + ); + await runCli( + "footnotes", + "add", + docPath, + "--anchor", + "Delaware", + "--text", + "See Del. Code.", + ); + const before = paragraphContaining( + await readDocumentXml(docPath), + "Governing law", + ); + + const result = await runCli("move", docPath, "--at", "p3", "--after", "p4"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("p4\n"); + + // The paragraph moved byte-for-byte: markers, reference, formatting. + const after = paragraphContaining( + await readDocumentXml(docPath), + "Governing law", + ); + expect(after).toBe(before); + const lines = await readLines(docPath); + expect(lines.slice(-3)).toEqual([ + "Clause C: Notices go to the address above.", + '**Clause B**: Governing law is Delaware[^fn1].', + "[^fn1]: See Del. Code.", + ]); + const comments = (await runCli("comments", "list", docPath)).parsed as { + anchor: { startBlockId: string }; + }[]; + expect(comments[0]?.anchor.startBlockId).toBe("p4"); + await expectValidClean(docPath); + }); +}); + +describe("move — sources", () => { + test("a range carries the blocks between its endpoints, tables included", async () => { + const docPath = await createDoc( + "move-range", + "A.\n\nB.\n\n| x | y |\n|---|---|\n| 1 | 2 |\n\nC.\n\nD.\n", + ); + const result = await runCli( + "move", + docPath, + "--at", + "p1-p2", + "--after", + "p3", + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("p2\nt0\np3\n"); + expect( + (await readLines(docPath)).filter((line) => !line.startsWith("|")), + ).toEqual(["A.", "D.", "B.", "C."]); + await expectValidClean(docPath); + }); + + test("--subtree moves a heading with everything up to the next same-level heading", async () => { + const docPath = await createDoc( + "move-subtree", + "# Intro\n\nIntro text.\n\n# Details\n\nDetail text.\n\n## Sub\n\nSub text.\n\n# Summary\n\nSummary text.\n", + ); + const result = await runCli( + "move", + docPath, + "--at", + "p2", + "--subtree", + "--after", + "p7", + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("p4\np5\np6\np7\n"); + expect(await readLines(docPath)).toEqual([ + "# Intro", + "Intro text.", + "# Summary", + "Summary text.", + "# Details", + "Detail text.", + "## Sub", + "Sub text.", + ]); + }); + + test("--subtree on a non-heading is refused", async () => { + const docPath = await createDoc("move-subtree-plain", "A.\n\nB.\n"); + const result = await runCli( + "move", + docPath, + "--at", + "p0", + "--subtree", + "--after", + "p1", + ); + expect(result.exitCode).toBe(2); + expect(result.stdout).toContain("--subtree needs a heading"); + }); + + test("a table moves whole", async () => { + const docPath = await createDoc( + "move-table", + "Lead.\n\n| x | y |\n|---|---|\n| 1 | 2 |\n\nTail.\n", + ); + const result = await runCli("move", docPath, "--at", "t0", "--after", "p1"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("t0\n"); + const lines = await readLines(docPath); + expect(lines[0]).toBe("Lead."); + expect(lines[1]).toBe("Tail."); + expect(lines[2]).toStartWith("| x"); + await expectValidClean(docPath); + }); +}); + +describe("move — table cells", () => { + const TABLE = "A one.\n\n| H1 | H2 |\n|---|---|\n| x | |\n\nB two.\n"; + + test("into a bare empty cell fills its mandatory paragraph", async () => { + const docPath = await createDoc("move-into-cell", TABLE); + const result = await runCli( + "move", + docPath, + "--at", + "p0", + "--after", + "t0:r1c1", + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("t0:r1c1:p0\n"); + const markdown = await readMarkdown(docPath); + expect(markdown).toContain("| A one. |"); + expect(markdown).not.toContain(""); + await expectValidClean(docPath); + }); + + test("out of a cell leaves Word's mandatory empty paragraph behind", async () => { + const docPath = await createDoc("move-out-of-cell", TABLE); + const result = await runCli( + "move", + docPath, + "--at", + "t0:r1c0:p0", + "--before", + "p0", + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("p0\n"); + const markdown = await readMarkdown(docPath); + expect(markdown).toContain("x "); + expect(markdown).toContain(""); + await expectValidClean(docPath); + }); +}); + +describe("move — text boxes", () => { + test("reorders paragraphs within one story; refuses leaving it", async () => { + const docPath = await buildRawDoc( + `${storyXml(["Body."])}${wordTextBoxParagraphXml(["First.", "Second."])}`, + "move-textbox", + ); + const result = await runCli( + "move", + docPath, + "--at", + "tbx0:p0", + "--after", + "tbx0:p1", + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("tbx0:p1\n"); + const xml = await readDocumentXml(docPath); + // Both branches of the AlternateContent carry the new order. + const stories = [ + ...xml.matchAll(/([\s\S]*?)<\/w:txbxContent>/g), + ].map((match) => match[1] ?? ""); + expect(stories).toHaveLength(2); + for (const story of stories) { + expect(story.indexOf("Second.")).toBeLessThan(story.indexOf("First.")); + } + + const across = await runCli( + "move", + docPath, + "--at", + "tbx0:p0", + "--after", + "p0", + ); + expect(across.exitCode).toBe(1); + expect(across.stdout).toContain('"code":"UNSUPPORTED"'); + }); +}); + +describe("move — refusals write nothing", () => { + async function expectRefused( + docPath: string, + args: string[], + exitCode: number, + message: string, + ): Promise { + const before = await readDocumentXml(docPath); + const result = await runCli("move", docPath, ...args); + expect(result.exitCode).toBe(exitCode); + expect(result.stdout).toContain(message); + expect(await readDocumentXml(docPath)).toBe(before); + } + + test("a move that lands where the blocks already are", async () => { + const docPath = await createDoc("move-noop", "A.\n\nB.\n\nC.\n"); + await expectRefused( + docPath, + ["--at", "p1", "--after", "p0"], + 2, + "already after p0", + ); + await expectRefused( + docPath, + ["--at", "p1", "--before", "p2"], + 2, + "already before p2", + ); + }); + + test("a destination inside the source", async () => { + const docPath = await createDoc("move-inside", "A.\n\nB.\n\nC.\n"); + await expectRefused( + docPath, + ["--at", "p0-p2", "--after", "p1"], + 2, + "part of what's being moved", + ); + }); + + test("a comment with one end inside the source", async () => { + const docPath = await createDoc( + "move-straddle", + "A one.\n\nB two.\n\nC three.\n", + ); + await runCli( + "comments", + "add", + docPath, + "--at", + "p0:0-p1:3", + "--text", + "hi", + ); + await expectRefused( + docPath, + ["--at", "p1", "--after", "p2"], + 1, + "Comment c0 starts on one side", + ); + // The whole range moves fine. + const result = await runCli( + "move", + docPath, + "--at", + "p0-p1", + "--after", + "p2", + ); + expect(result.exitCode).toBe(0); + await expectValidClean(docPath); + }); + + test("a paragraph carrying a section break, and a section-break anchor", async () => { + const docPath = await createDoc("move-sectpr", "A.\n\nB.\n\nC.\n"); + await runCli("sections", docPath, "--at", "p0-p0", "--columns", "2"); + const ids = (await runCli("read", docPath, "--ast")).parsed as { + blocks: { id: string; type: string }[]; + }; + const sentinel = ids.blocks.findIndex( + (block) => block.type === "sectionBreak", + ); + const carrier = ids.blocks[sentinel - 1]?.id as string; + await expectRefused( + docPath, + ["--at", carrier, "--after", "p3"], + 1, + "carries a section break", + ); + await expectRefused( + docPath, + ["--at", "p3", "--after", "s0"], + 2, + "can't anchor a move", + ); + }); + + test("track changes on", async () => { + const docPath = await createDoc("move-tracked", "A.\n\nB.\n"); + await runCli("track-changes", "on", docPath); + await expectRefused( + docPath, + ["--at", "p0", "--after", "p1"], + 1, + "tracked moves aren't supported yet", + ); + }); + + test("usage errors", async () => { + const docPath = await createDoc("move-usage", "A.\n\nB.\n"); + await expectRefused(docPath, ["--at", "p0"], 2, "Missing destination"); + await expectRefused( + docPath, + ["--at", "p0", "--after", "p1", "--before", "p1"], + 2, + "exactly one of --after or --before", + ); + await expectRefused( + docPath, + ["--at", "p0:0-1", "--after", "p1"], + 2, + "whole blocks", + ); + await expectRefused( + docPath, + ["--at", "p9", "--after", "p1"], + 3, + "Block not found", + ); + }); + + test("--dry-run validates but writes nothing", async () => { + const docPath = await createDoc("move-dry", "A.\n\nB.\n"); + const before = await readDocumentXml(docPath); + const result = await runCli( + "move", + docPath, + "--at", + "p0", + "--after", + "p1", + "--dry-run", + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('"dryRun":true'); + expect(await readDocumentXml(docPath)).toBe(before); + }); +}); + +describe("move --batch", () => { + async function batchFile(lines: object[]): Promise { + const path = join(tempWorkspace("move-batch-jsonl"), "moves.jsonl"); + await Bun.write(path, lines.map((line) => JSON.stringify(line)).join("\n")); + return path; + } + + test("locators address the document as read; stacked moves keep entry order", async () => { + const docPath = await createDoc( + "move-batch", + "A.\n\nB.\n\nC.\n\nD.\n\nE.\n", + ); + const batch = await batchFile([ + { at: "p0", after: "p4" }, + { at: "p1", after: "p4" }, + { at: "p3", before: "p2" }, + ]); + const result = await runCli("move", docPath, "--batch", batch); + expect(result.exitCode).toBe(0); + expect(await readLines(docPath)).toEqual(["D.", "C.", "E.", "A.", "B."]); + expect(result.stdout).toBe("p0\np3\np4\n"); + }); + + test("an empty cell's paragraph moved out by one entry isn't reused by another", async () => { + const docPath = await createDoc( + "move-batch-cell", + "A one.\n\n| H1 | H2 |\n|---|---|\n| x | |\n\nB two.\n", + ); + const result = await runCli( + "move", + docPath, + "--batch", + await batchFile([ + { at: "t0:r1c1:p0", after: "p1" }, + { at: "p0", after: "t0:r1c1" }, + ]), + ); + expect(result.exitCode).toBe(0); + expect(await readMarkdown(docPath)).toContain( + "| A one. |", + ); + await expectValidClean(docPath); + }); + + test("overlapping sources and anchors on moved blocks are refused", async () => { + const docPath = await createDoc("move-batch-bad", "A.\n\nB.\n\nC.\n"); + const before = await readDocumentXml(docPath); + const overlap = await runCli( + "move", + docPath, + "--batch", + await batchFile([ + { at: "p0-p1", after: "p2" }, + { at: "p1", after: "p2" }, + ]), + ); + expect(overlap.exitCode).toBe(2); + expect(overlap.stdout).toContain("entry 1: p1 overlaps p0-p1"); + + const anchored = await runCli( + "move", + docPath, + "--batch", + await batchFile([ + { at: "p0", after: "p2" }, + { at: "p1", before: "p0" }, + ]), + ); + expect(anchored.exitCode).toBe(2); + expect(anchored.stdout).toContain("entry 1: p0 is moved by entry 0"); + expect(await readDocumentXml(docPath)).toBe(before); + }); + + test("rejects --at beside --batch and malformed entries", async () => { + const docPath = await createDoc("move-batch-usage", "A.\n\nB.\n"); + const batch = await batchFile([{ at: "p0", after: "p1" }]); + const mixed = await runCli("move", docPath, "--batch", batch, "--at", "p0"); + expect(mixed.exitCode).toBe(2); + const malformed = await runCli( + "move", + docPath, + "--batch", + await batchFile([{ at: "p0", to: "p1" }]), + ); + expect(malformed.exitCode).toBe(2); + expect(malformed.stdout).toContain(String.raw`unknown key \"to\"`); + }); +}); diff --git a/tests/fixtures/footnotes-mutations.docx b/tests/fixtures/footnotes-mutations.docx index 8c8006b..f77c565 100644 Binary files a/tests/fixtures/footnotes-mutations.docx and b/tests/fixtures/footnotes-mutations.docx differ diff --git a/tests/fixtures/setup/footnotes-mutations.ts b/tests/fixtures/setup/footnotes-mutations.ts index 3b8a245..8acef6f 100644 --- a/tests/fixtures/setup/footnotes-mutations.ts +++ b/tests/fixtures/setup/footnotes-mutations.ts @@ -24,6 +24,10 @@ process.env.DOCX_CLI_NOW ??= "2026-05-22T00:00:00Z"; * * footnotes.xml: separator, continuationSeparator, fn1 (edited), fn2, fn3 * endnotes.xml: separator, continuationSeparator, en1 + * + * The endnote paragraph is authored right after p0 and then relocated with + * `docx move` (dogfooding it: the endnote reference rides the moved node), + * so LibreOffice round-trips a moved note reference too. */ const root = resolve(import.meta.dir, "../../.."); @@ -38,13 +42,16 @@ mkdirSync(dirname(out), { recursive: true }); await cli("create", out, "--force", "--text", "Footnotes fixture"); -await cli("insert", out, "--after", "p0", "--text", "Anchored at end."); +await cli("insert", out, "--after", "p0", "--text", "Endnote here."); +await cli("endnotes", "add", out, "--at", "p1", "--text", "Body en1."); + +await cli("insert", out, "--after", "p1", "--text", "Anchored at end."); await cli( "footnotes", "add", out, "--at", - "p1", + "p2", "--text", "Original body fn1.", ); @@ -58,16 +65,16 @@ await cli( "Edited body fn1.", ); -await cli("insert", out, "--after", "p1", "--text", "Anchored at start. Body."); +await cli("insert", out, "--after", "p2", "--text", "Anchored at start. Body."); // Offset 0 = before "Anchored…" — first character. -await cli("footnotes", "add", out, "--at", "p2:0", "--text", "Body fn2."); +await cli("footnotes", "add", out, "--at", "p3:0", "--text", "Body fn2."); -await cli("insert", out, "--after", "p2", "--text", "Anchored mid-run."); +await cli("insert", out, "--after", "p3", "--text", "Anchored mid-run."); // 8 = right after "Anchored", mid-run. -await cli("footnotes", "add", out, "--at", "p3:8", "--text", "Body fn3."); +await cli("footnotes", "add", out, "--at", "p4:8", "--text", "Body fn3."); -await cli("insert", out, "--after", "p3", "--text", "Endnote here."); -await cli("endnotes", "add", out, "--at", "p4", "--text", "Body en1."); +// Endnote paragraph p1 → after the footnote paragraphs (the final p4). +await cli("move", out, "--at", "p1", "--after", "p4"); // One tracked footnote so the integration suite covers the under-tracking // emit path too: wraps both the reference run AND the body content,