Skip to content

feat(book): 结构级自由编辑 API — book.write op insert/delete/rename/move, course.book.rename/move/delete (#459 #470 #458) - #480

Merged
EricWang1358 merged 12 commits into
mainfrom
codex/book-structure-api
Oct 25, 2026
Merged

EricWang1358 merged 12 commits into
mainfrom
codex/book-structure-api

Conversation

@EricWang1358

@EricWang1358 EricWang1358 commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

PR 1 of the review book's structure-level editing (#470, #459, #458 item 4; tracker #469). API, docs and capability text only; the web UI (更多 › 改名 / 移动到… / 删除, ToC reorder) is PR 2.

What changed

book.write section ops (#459), new lib/book-section-ops.js (pure). op: insert | delete | rename | move, by heading id (never by title):

  • insert {section: {heading, markdown}, after | before | within: id | position: 'start' | 'end'}: the new section in its place instead of the end of the file. A heading of the same title (spaces/case ignored) is refused with book-section-exists, so the "十一、性能测试 ×3" pattern cannot happen through insert. The answer carries section: {id, title}.
  • delete {heading, children?: 'promote' | 'delete' | 'toUnplaced'}: nothing left in its place. promote (default) lifts sub-sections one level; toUnplaced also writes 未归位 with the section's footnote definitions (labels renamed), and that file's previous version goes to the history too.
  • rename {heading, title} (id, level, body kept); move {heading, after | before | within | position} (sub-sections go along, levels follow the new place, never inside itself).
  • Same 20 000-character rule, footnote settling and version semantics as a section write. Unknown op: book-write-op, the message names insert, delete, rename, move. The old section mode is unchanged (a title the file does not have is still appended, the warning now points at op insert).

Book structure (#470), new lib/course-book-structure.js (+ lib/course-book-own-titles.js for what the rebuild needs):

  • course.book.rename {course?, hid, title, version}, course.book.move {course?, hid, toParent?, afterHid?, beforeHid?, version}, course.book.delete {course?, hid, children?: 'promote' | 'toUnplaced', version}.
  • version = the book's new structureVersion (in course.book.open and book.toc); stale is refused with course-book-conflict (no merge: a structure change on a stale tree has no safe merge). A rebuilt outline also moves it.
  • Same machinery as course.book.save: the book's writer queue, one store transaction, the structure before the change pushed to the version history as the entry book.structure (manifest, own titles, every file the change removed or changed). course.book.revert {file: 'book.structure'} undoes the latest one; undos in a row work (the files are checked by content hash, not version); refused after an outline rebuild or when a touched file was written since.
  • Rename = a book-only display title ([复习全书·自由编辑] 结构级自由编辑未落地:改名/移动/增删节点既无 API 也无 UI 入口,总纲被文档明令禁止手动编辑 #470 item 4, the "local override title"): the outline is never touched; bookDoc.ownTitles keeps it by heading id, and a rebuilt outline is matched against the outline's own titles and then keeps the learner's title on the same id.
  • Move takes the node with its subtree; the point's files are named by the point so they go along; the headings inside its files shift with the depth change (the file versions are part of the structure snapshot, so undo restores them).
  • Delete: promote (default) puts the children in its place; toUnplaced removes the subtree and moves only the learner's text (mine, qa, a forked gen) into 未归位 under ### title, footnotes renamed [^<hid><g|m|q>-n]; points with nothing of the learner's leave nothing. A parent left empty is reported (emptied), not removed.
  • Answers are small by default ({course, write, hid, …, structureVersion, revision, history, note}), answer: 'book' returns the whole book like course.book.save.

Schemas (#465 family): lib/contexts/library/book-schemas.js, attached in lib/runtime/builtins.js to the library operations: course.book.rename/move/delete (required hid, version), course.book.save and book.write (key names only: no type, no required, no enums, so every mistake reaches the action's own coded refusal, including #471's course-book-text-missing / -version-required; tested). Round 2 shortened all five to key names plus one short line: study_capabilities {context:'library'} is 9 834 characters (main before this PR: 8 385), bounded by a test. study_capabilities {operation} shows them.

#458 item 4: FILE_TEXT_MAX 400 000 → 2 000 000. Only lib/course-book-edit.js uses it; lib/transcript.js:226 (audio volume split), lib/audio-batch-members.js:15 (VOLUME_LIMIT) and the bulk-import "50 万字符" message are different limits and are untouched. The #471 test and docs that named 400 000 now follow the constant.

Also: library.context {area: 'book'} text (appended after the object so it stayed clear of #471's lines), skills/study-book/SKILL.md (Restructuring section), docs en + zh-CN (review-book, review-book-agent, course-outline: the outline still cannot be hand-edited, the book structure can), 6 English entries in lib/application-messages-en.js, changelog fragment book-structure-api.

Not done (on purpose)

Tests

  • tests/course-book-structure.test.mjs (new): [复习全书·自由编辑] 结构级自由编辑未落地:改名/移动/增删节点既无 API 也无 UI 入口,总纲被文档明令禁止手动编辑 #470 acceptance on a 14-point / 4-chapter book (rename + move without outline rebuild and without book text, stale version refused, undo walks back), delete without shells (point, chapter promote, chapter toUnplaced with footnotes), depth change shifts file headings and undo restores them, refusal codes + bilingual messages, concurrent changes in one queue, rename kept across an outline rebuild (and its undo refused after the rebuild), schemas in study_capabilities, 2 000 000-character file.
  • tests/book-agent-write-ops.test.mjs (new): insert at each place, same-title refusal, rename/move/delete (promote, delete, toUnplaced), unknown op names the allowed ones, nothing written on refusal, history/undo.
  • node scripts/test.mjs --fast (after rebasing on fix(book-api): 报错说清真因 — course.book.save / book.read / source.courses.set (#458 #465 #466) #471): 7692 tests, 7689 pass, 2 skipped, 1 fail: docs-links (course-outline.md linked review-book.md, which does not ship in the package). Fixed in the last commit by naming the section in words; node --test tests/docs-links.test.mjs then 3/3. Two later small commits (exact-hid link filter, that docs fix) were checked with their own test files only.

Not verified

  • Not run against a real model in a real chat (the skill text is new); not tried on the owner's 495 496-character book.
  • Not checked in the browser (no UI change in this PR; course.book.open only gained structureVersion).

Review fixes (#480 review, commit 43d75ba)

  1. Every file a structure change rewrites (heading shift on move, 未归位 on delete) now gets its own file-history entry, so that file's 回退到上一版 steps back exactly that write; the structure undo also records the files it rewrites. Tests for both probes (mine T1, T2 -> move -> revert -> T2 then T1; 未归位 U0 -> delete toUnplaced -> revert -> U0).
  2. A deleted point's learner text (mine, qa, forked gen) always goes to 未归位, also with the default promote; refused with course-book-unplaced-full when 未归位 would pass FILE_TEXT_MAX (book.write's toUnplaced checks it too, before anything is written). History: structure entries have their own allowance (newest 10, STRUCTURE_KEPT), files keep 20; HISTORY_CHARS = 3 x FILE_TEXT_MAX. Test: delete with notes -> notes in 未归位 -> 20 small writes -> structure undo still works.
  3. course.book.rename/move/delete (and the structure undo) bring the book up to date first, then compare-and-set; a version read before an outline rebuild is refused and nothing changes (test).
  4. Schemas as above; test that missing/mistyped text, version, hid, section, edits, op still get their coded refusals.
  5. sameTitle ignores all whitespace, letter case and full-width / half-width forms of a punctuation mark (、 = comma, 。 = full stop); words, digits and numbering count. Tested with 十一、性能测试 variants vs 十二、性能测试 / 11、性能测试.
  6. A move (node or section) or an insert that would put a heading past level 6 is refused instead of flattened.
  7. Docs say chapter renames survive a rebuild only when the new outline titles the chapter as before (tested); a lost point goes to 未归位 under the title the book showed (one-line change in openCourseBook, tested).
  8. Docs and changelog: an emptied chapter stays; undo works until the outline is made again (重新整理, or 更新全书 when it makes a new outline) and for the last 10 structure changes.

node scripts/test.mjs --fast on 43d75ba: 7698 tests, 7696 pass, 0 fail, 2 skipped.

Review fixes, round 2 (commit b2d3f1f; merged with main at 4479e20)

HIGH: no undo or revert leaves a text with no copy.

  • Structure undo is never refused because of a file. A touched file that still holds what the change left gets its text from before back (the change's own entry for that file leaves the history); one that already holds that text again is left; one written since (an edit, a 回退到上一版) is left as it is and named in kept. One entry can no longer block older ones; no refusal advises a destructive revert.
  • A file's 回退到上一版 keeps the text it replaces as a redo entry (redo: true, not a version, skipped by lastOf / historyCounts); course.book.revert {file, redo: true} brings it back (and keeps what it replaces as a version).
  • Tests: T1 (notes in 未归位 -> 未归位 reverted -> structure undo puts the notes back in their point; redo can bring the 未归位 copy back), T7 (edit after a move stays through the undo; the file's revert and redo), stuck stack (two changes with an edit between, both undone). The old "Probe 2" sequence test is replaced. Learner docs no longer suggest reverting 未归位.

MEDIUM

  1. rename / move / delete of a heading no longer re-check or renumber footnotes (a learner's non-quote note survives). Round 2 did this by mapping fix(book): footnotes stay one list through book.write; a footnote mark is never part of a quote (#460, #467) #477's ids back to plain labels, which was wrong for files with several 出处 lists sharing a key; round 3 replaces it (below).
  2. insert / rename refuse a duplicate title; a whole-file, section or edits write that leaves a title twice warns. Docs and changelog say exactly that.
  3. 未归位 copies: every footnote label renamed (CJK too) outside inline and fenced code; only the gone point's own generated 练 / 问答 line is left out, the learner's lines stay. Tested with two points using [^注].
  4. Schemas shortened (above); size test.
  5. A point's file headings shift together (levels to each other kept); a move that would push one above level 1 is refused (course-book-move-invalid). Tested.

LOW
6. The structure snapshot holds only the text of files it removed; rewritten files are a reference + hash to their own entry of the same write; the undo takes those entries back. HISTORY_CHARS = 2 x FILE_TEXT_MAX; structure allowance: newest 10, at most FILE_TEXT_MAX of text.
7. English catalogue entries for the level-6 / level-1 refusals, the deleted-point save message and "nothing to redo".
8. Docs: course-book-version-required is not listed for rename / move / delete (the input schema answers first, without a code); the undo limits are stated.
9. A save of a file whose point is gone says the point was deleted (or left out by a rebuilt outline), its text is in 未归位, and to copy any unsaved editor text first (code unchanged, course-book-file-missing). The editor does not keep the draft by itself (not done).

Merge with main (#476, #477, #478, #481): #478's park() carries the learner's title (titleOf: the title the book showed, also in heldList); #477's footnote checks (known, footnoteProblems, borrowed-mark warning) apply to every write except rename / move / delete, which keep the file's footnotes as they are; docs keep both sides.

node scripts/test.mjs --fast on 4479e20: 7744 tests, 7742 pass, 0 fail, 2 skipped.

Review fixes, round 3 (commit 1a56a24, main merged in before it)

HIGH

  1. Structural ops edit the raw text. op rename / move / delete never go through split/compose/settle any more (writeStructure in lib/book-agent-write.js). Every mark, footnote and 出处 list stays byte for byte. A section that moves or goes takes along only the lists nothing outside it cites; a list cited from outside stays where it is (new lib/book-footnote-binding.js reads what each mark cites as fix(book): footnotes stay one list through book.write; a footnote mark is never part of a quote (#460, #467) #477's takeFootnotes does: first definition of its key after the mark, else the last). A section put at the end of another goes before that section's trailing 出处 list, or after it when a mark would otherwise cite another footnote. After every op the citations are compared and a change is refused (book-footnote-binding; round 3 compared counts, round 4 compares mark by mark, see below). A delete takes along footnotes only its own marks cited (and a label left empty). toUnplaced copies the other footnotes its marks cite into 未归位, all its labels renamed [^<hid>x<n>-<label>]. Test: a file with two lists sharing [^1] (甲 / 乙) through rename, move, move within, delete promote, insert and delete toUnplaced, each followed by an ordinary section write: both notes survive, each mark cites its own note, and the bytes are unchanged after the structural op.
  2. The toUnplaced answer now says to put the section back by reverting the point's file, never unplaced.md (its copy may be the only one). Docs and skill say the same.
  3. One implementation for parked text: new lib/book-parked-text.js (relabelFootnotes, cutOwnLinks) is used by fix(outline): a rebuild asks first and holds notes no new point fits in 未归位 (split notes follow their biggest part); granularity / maxPoints (#461, #462) #478's park(), the fold path, course.book.delete and the toUnplaced section. It matches the exact heading (h2 is not h20). A line holding only the gone point's 练 / 问答 links is dropped; on a line with the learner's words only the link markup goes and the words stay. Code spans and fences are untouched, and CJK labels are renamed. Test: your reproduction (知识点 0 = h2 with a link to 知识点 14 = h20, inline code [^abc], [^注]): after a rebuild drops 知识点 0, all of it is intact in 未归位.

MEDIUM
4. course.book.revert {file: 'book.structure', redo: true} is refused with course-book-no-redo. Tested.
5. The structure undo answers small by default (write, kept, structureVersion, revision, history, and a note telling the chat to tell the learner and never revert the kept files); the whole book only with answer: 'book'. Size and kept tested.
6. The deleted-point wording appears only for points course.book.delete removed (bookDoc.deleted, newest 200), with separate sentences for "its text is in 未归位" and "it held nothing of yours". Any other missing file keeps the neutral 书里没有这份文件 / The book has no such file. All three cases tested.
7. Docs, changelog and skill state the real guarantees. The notes are moved into 未归位 (the copy that stays). Undo and redo copies live in the version history, which keeps a limited number of versions. Stepping 未归位 back takes the notes out again, after which they come back only through the undo (while the outline is the same) or that redo.

node scripts/test.mjs --fast on 1a56a24: 7763 tests, 7761 pass, 0 fail, 2 skipped.

Review fixes, round 4 (commit 2fd998a; main had not moved)

  1. A promote-delete shifts the sub-sections as one slice, so a code fence in them keeps its lines (# compute the sum inside ```python stays a comment, not a heading). Test from p1.
  2. The binding guard compares each mark separately. Every mark is tagged through the op (tagMarks / markCitations in lib/book-footnote-binding.js) and must cite the same footnote text afterwards, so a swap of two marks' notes is caught. When the place before the target's trailing 出处 list would swap them, the place after that list is used. If both places would change a citation the op is refused with nothing written. A rename compares only the marks left, since a mark in the renamed heading line itself is replaced. Tests: the [复习全书·写入] book.write 只有 markdown/section/edits,缺 insert/delete/rename/move,补写的小节全堆到文件末尾 #459/[复习全书·自由编辑] 结构级自由编辑未落地:改名/移动/增删节点既无 API 也无 UI 入口,总纲被文档明令禁止手动编辑 #470 layout (十一 with its own [^1] list moved after or within 三), probe7's P13 case, and a safe move that is taken as asked.
  3. bookDoc.deleted is part of the structure snapshot, and an undo removes the points it brings back. Test (probe3 P7 / r4c K): a point restored by an undo, then a rebuild that gives it a new id; a save of its old file name gets the neutral sentence.
  4. The structural ops read lines inside code fences as code (no mark, definition, list or placement there), so a move or delete never tears a fence apart, and the fence goes to 未归位 whole. Test from p2.

Known limit (#477's code, left as is): the ordinary writes (markdown, section, edits, insert) still read a [^n]: … line inside a code fence as a footnote. The docs say so.

node scripts/test.mjs --fast on 2fd998a: 7769 tests, 7767 pass, 0 fail, 2 skipped.

Review fixes, round 5 (commit 558c3d2, after merging main at #482)

  • The same-title check (sameTitle) no longer sees the internal per-mark tags, and it also ignores footnote marks: 甲[^1] is the title 甲, so renaming or inserting 甲, 甲[^1] or 甲 [^1] next to a heading 甲[^1] is refused with book-section-exists. The docs say footnote marks are ignored.
  • The move answer's section.title comes back clean (no internal tag). Test: a heading that carries a mark is renamed onto, inserted onto and moved; no U+0001 appears in the answer or in the file.

node scripts/test.mjs --fast on 558c3d2: 7784 tests, 7782 pass, 0 fail, 2 skipped.

Known limits

  • Ordinary writes (markdown, section, edits, insert) go through fix(book): footnotes stay one list through book.write; a footnote mark is never part of a quote (#460, #467) #477's footnote reading, which takes a [^n]: … line inside a code fence for a footnote. The structural ops treat fenced lines as code.
  • While a structural op runs, each footnote mark carries a temporary tag U+0001 digits U+0001. If the learner's text already contained exactly that control-character sequence, it would be removed with the tags. I did not change this to a per-call marker because it was not trivial.
  • Moves and deletes of chapters and points are not kept after an outline rebuild (重新整理, or 更新全书 when it makes a new outline). A chapter's own title survives only if the new outline names the chapter as before.
  • After an outline rebuild a structure undo is refused, and older structure entries are blocked with it. The text is not lost: the notes are in 未归位 or followed their points.
  • Undo and redo copies live in the version history: files keep 20 entries / 2 × FILE_TEXT_MAX of text, structure changes keep 10 / FILE_TEXT_MAX. Stepping 未归位 back takes notes out of it; afterwards they return only through the structure undo or the redo, while those entries are still kept.
  • The book page has no buttons for any of this yet (PR 2).
  • Merging or splitting knowledge points is not supported.
  • Moving a section into another point is not an op.
  • After a failed save on a deleted point, the editor does not keep the draft by itself. The message asks the learner to copy it first.

…item 4)

book.write gains op insert/delete/rename/move by heading id (lib/book-section-ops.js); course.book.rename/move/delete change the book's chapters and points with compare-and-set on structureVersion, in the writer queue and one transaction, the structure before kept in the version history as book.structure (course.book.revert undoes). Renamed titles survive an outline rebuild (lib/course-book-own-titles.js). Input schemas for the book's write actions in study_capabilities. FILE_TEXT_MAX 400 000 -> 2 000 000. Docs (en + zh-CN), skill study-book, changelog fragment.
Every file a structure change rewrites gets its own history entry (file revert steps back one write); a deleted point's learner text always goes to 未归位 (refused when it would pass FILE_TEXT_MAX); structure entries have their own history allowance and HISTORY_CHARS follows FILE_TEXT_MAX; the book is brought up to date before the compare-and-set (a pre-rebuild version is refused); schemas of course.book.save / book.write only describe; sameTitle ignores whitespace, case and full/half-width punctuation; moves past heading level 6 refused; lost points go to 未归位 under the book's title; docs and changelog say exactly what survives a rebuild and how long undo works.
…view round 2)

Structure undo restores what still holds the change's output and leaves every file written since as it is (kept), never refused because of a file; a file's revert keeps what it replaces as a redo entry (course.book.revert {file, redo:true}); the snapshot names rewritten files by their entry of the same write, and the undo takes those entries back. Structural section ops keep the file's own footnotes untouched; duplicate titles are warned in every write; 未归位 copies relabel only real footnotes (CJK too, not in code) and keep the learner's lines; schemas shortened (library listing 9 834 chars); headings shift together, a move above level 1 is refused; the deleted point's save says where its text is; catalogue entries; docs and changelog say the exact undo limits.
# Conflicts:
#	docs/course-outline.md
#	docs/course-outline.zh-CN.md
#	docs/review-book-agent.md
#	docs/review-book-agent.zh-CN.md
#	lib/book-agent-read.js
#	lib/book-agent-write.js
#	lib/course-book-files.js
…implementation (#480 review round 3)

rename / move / delete of a heading never go through the footnote pipeline: marks, footnotes and every 出处 list stay byte for byte, a moving or deleted section takes along only the lists nothing outside cites, the bindings are checked after the op (book-footnote-binding); toUnplaced copies the cited footnotes and relabels in 未归位. park(), fold and course.book.delete share lib/book-parked-text.js (exact heading match, only the gone point's link cut, code untouched, CJK labels). No structure redo (course-book-no-redo); the structure undo answers small with kept; the deleted-point message only for points course.book.delete removed; docs say the real guarantees.
…fences; an undone delete is no longer 'deleted' (#480 review round 4)

Every mark is tagged through the op and must cite the same footnote after it (a swap is caught; the place before the trailing 出处 list is then given up for the one after it); fenced lines are code for the binding, lists and placement; a promote-delete shifts the sub-sections as one slice (no '# comment' in a fence becomes a heading); bookDoc.deleted is in the structure snapshot and an undo removes the points it brings back. Docs say what the guard checks and the known limit of the ordinary writes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant