Repository navigation
feat(book): 结构级自由编辑 API — book.write op insert/delete/rename/move, course.book.rename/move/delete (#459 #470 #458) - #480
Merged
Conversation
…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.
… id (h12, not h120)
… do not ship in the package)
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.
…e move answer (#480 review round 5)
This was referenced Oct 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.writesection ops (#459), newlib/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 withbook-section-exists, so the "十一、性能测试 ×3" pattern cannot happen through insert. The answer carriessection: {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).versionsemantics as a section write. Unknown op:book-write-op, the message names insert, delete, rename, move. The oldsectionmode 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.jsfor 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 newstructureVersion(incourse.book.openandbook.toc); stale is refused withcourse-book-conflict(no merge: a structure change on a stale tree has no safe merge). A rebuilt outline also moves it.course.book.save: the book's writer queue, one store transaction, the structure before the change pushed to the version history as the entrybook.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.bookDoc.ownTitleskeeps 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.### 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.{course, write, hid, …, structureVersion, revision, history, note}),answer: 'book'returns the whole book likecourse.book.save.Schemas (#465 family):
lib/contexts/library/book-schemas.js, attached inlib/runtime/builtins.jsto the library operations:course.book.rename/move/delete(requiredhid,version),course.book.saveandbook.write(key names only: no type, norequired, 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_MAX400 000 → 2 000 000. Onlylib/course-book-edit.jsuses 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 inlib/application-messages-en.js, changelog fragmentbook-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 instudy_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.mjsthen 3/3. Two later small commits (exact-hid link filter, that docs fix) were checked with their own test files only.Not verified
course.book.openonly gainedstructureVersion).Review fixes (#480 review, commit 43d75ba)
promote; refused withcourse-book-unplaced-fullwhen 未归位 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.node scripts/test.mjs --faston 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.
kept. One entry can no longer block older ones; no refusal advises a destructive revert.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).MEDIUM
[^注].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 inheldList); #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 --faston 4479e20: 7744 tests, 7742 pass, 0 fail, 2 skipped.Review fixes, round 3 (commit 1a56a24, main merged in before it)
HIGH
writeStructurein 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.unplaced.md(its copy may be the only one). Docs and skill say the same.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'spark(), 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 withcourse-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 withanswer: 'book'. Size andkepttested.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 --faston 1a56a24: 7763 tests, 7761 pass, 0 fail, 2 skipped.Review fixes, round 4 (commit 2fd998a; main had not moved)
# compute the suminside ```python stays a comment, not a heading). Test from p1.tagMarks/markCitationsin 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.bookDoc.deletedis 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.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 --faston 2fd998a: 7769 tests, 7767 pass, 0 fail, 2 skipped.Review fixes, round 5 (commit 558c3d2, after merging main at #482)
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.section.titlecomes 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 --faston 558c3d2: 7784 tests, 7782 pass, 0 fail, 2 skipped.Known limits
[^n]: …line inside a code fence for a footnote. The structural ops treat fenced lines as code.