mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-30 17:22:02 +08:00
feat(core): delete the latest chapter with state rollback
复用审阅"拒绝章节"的回滚机制(StateManager.rollbackToChapter),提供 正式的删除最新章能力,解决用户手动删章要同时清理索引、状态、快照、 runtime 文件的痛点: - core 新增 deleteLatestChapter():只允许删最新章(删中间章需要重编号 后续章节并重放状态,v1 明确不支持并在报错里说明);删除前先校验回滚 目标章的状态快照存在,避免出现改到一半的状态;章节 markdown 移入 chapters/.trash/(重名自动加 -2/-3 后缀),不做物理删除;然后调用 rollbackToChapter 一次性回滚 index.json、story 状态、快照、runtime 产物和 sqlite 记忆索引。 - entity rename 的文件收集跳过点开头目录(如 chapters/.trash), 回收站内容不再被全书改名波及。 - CLI 新增 inkos chapter delete <book-id> [--chapter N],默认删最新章, 确认交互沿用 book delete 的 (y/N) + --force 惯例,支持 --json, 提示语按书籍语言双语显示。 不加聊天 agent 工具:破坏性操作先只开 CLI 入口,聊天入口等有确认卡 语义后再说。对应 GitHub issue #339 的第二项诉求(一键删除章节)。
This commit is contained in:
@@ -43,6 +43,7 @@ async function setupBook(params: {
|
||||
readonly bookId: string;
|
||||
readonly chapters: ReadonlyArray<{ readonly file: string; readonly content: string }>;
|
||||
readonly index: ReadonlyArray<ChapterEntry>;
|
||||
readonly snapshotChapters?: ReadonlyArray<number>;
|
||||
}): Promise<string> {
|
||||
projectRoot = await mkdtemp(join(tmpdir(), "inkos-chapter-cmd-"));
|
||||
const bookDir = join(projectRoot, "books", params.bookId);
|
||||
@@ -56,6 +57,17 @@ async function setupBook(params: {
|
||||
await writeFile(join(bookDir, "chapters", chapter.file), chapter.content, "utf-8");
|
||||
}
|
||||
await writeFile(join(bookDir, "chapters", "index.json"), JSON.stringify(params.index, null, 2), "utf-8");
|
||||
|
||||
const storyDir = join(bookDir, "story");
|
||||
await mkdir(storyDir, { recursive: true });
|
||||
await writeFile(join(storyDir, "current_state.md"), "state after latest", "utf-8");
|
||||
await writeFile(join(storyDir, "pending_hooks.md"), "hooks after latest", "utf-8");
|
||||
for (const snapshotChapter of params.snapshotChapters ?? []) {
|
||||
const snapshotDir = join(storyDir, "snapshots", String(snapshotChapter));
|
||||
await mkdir(snapshotDir, { recursive: true });
|
||||
await writeFile(join(snapshotDir, "current_state.md"), `state at ${snapshotChapter}`, "utf-8");
|
||||
await writeFile(join(snapshotDir, "pending_hooks.md"), `hooks at ${snapshotChapter}`, "utf-8");
|
||||
}
|
||||
return bookDir;
|
||||
}
|
||||
|
||||
@@ -104,3 +116,62 @@ describe("inkos chapter sync", () => {
|
||||
expect(printed).toContain("无需修正");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inkos chapter delete", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("deletes the latest chapter with --force and prints JSON with --json", async () => {
|
||||
const bookDir = await setupBook({
|
||||
bookId: "delbook",
|
||||
chapters: [
|
||||
{ file: "0001_起风.md", content: "第一章。" },
|
||||
{ file: "0002_落雨.md", content: "第二章。" },
|
||||
],
|
||||
index: [chapterEntry(1, "起风", 4), chapterEntry(2, "落雨", 4)],
|
||||
snapshotChapters: [1, 2],
|
||||
});
|
||||
|
||||
const { chapterCommand } = await import("../commands/chapter.js");
|
||||
await chapterCommand.parseAsync(["node", "chapter", "delete", "delbook", "--force", "--json"], { from: "node" });
|
||||
|
||||
expect(logErrorMock).not.toHaveBeenCalled();
|
||||
const output = JSON.parse(logMock.mock.calls.at(-1)?.[0] as string) as {
|
||||
deletedChapter: number;
|
||||
rolledBackTo: number;
|
||||
trashedFiles: ReadonlyArray<string>;
|
||||
};
|
||||
expect(output.deletedChapter).toBe(2);
|
||||
expect(output.rolledBackTo).toBe(1);
|
||||
expect(output.trashedFiles).toEqual(["chapters/.trash/0002_落雨.md"]);
|
||||
|
||||
const savedIndex = JSON.parse(await readFile(join(bookDir, "chapters", "index.json"), "utf-8")) as ChapterEntry[];
|
||||
expect(savedIndex.map((c) => c.number)).toEqual([1]);
|
||||
await expect(readFile(join(bookDir, "chapters", ".trash", "0002_落雨.md"), "utf-8"))
|
||||
.resolves.toBe("第二章。");
|
||||
});
|
||||
|
||||
it("fails with exit code 1 when asked to delete a non-latest chapter", async () => {
|
||||
await setupBook({
|
||||
bookId: "midbook",
|
||||
chapters: [
|
||||
{ file: "0001_起风.md", content: "第一章。" },
|
||||
{ file: "0002_落雨.md", content: "第二章。" },
|
||||
],
|
||||
index: [chapterEntry(1, "起风", 4), chapterEntry(2, "落雨", 4)],
|
||||
snapshotChapters: [1, 2],
|
||||
});
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
try {
|
||||
const { chapterCommand } = await import("../commands/chapter.js");
|
||||
await chapterCommand.parseAsync(["node", "chapter", "delete", "midbook", "--chapter", "1", "--force"], { from: "node" });
|
||||
|
||||
expect(logErrorMock).toHaveBeenCalledWith(expect.stringContaining("latest chapter"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Command } from "commander";
|
||||
import { StateManager, syncChapterWordCounts } from "@actalk/inkos-core";
|
||||
import { createInterface } from "node:readline";
|
||||
import { deleteLatestChapter, StateManager, syncChapterWordCounts } from "@actalk/inkos-core";
|
||||
import {
|
||||
formatChapterDeleteCancelled,
|
||||
formatChapterDeleteConfirm,
|
||||
formatChapterDeleteDone,
|
||||
formatChapterSyncChange,
|
||||
formatChapterSyncMissingFiles,
|
||||
formatChapterSyncNoChanges,
|
||||
@@ -52,3 +56,62 @@ chapterCommand
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
chapterCommand
|
||||
.command("delete")
|
||||
.description("Delete the latest chapter: move its file to chapters/.trash/ and roll the index and story state back")
|
||||
.argument("<book-id>", "Book ID")
|
||||
.option("--chapter <n>", "Chapter number to delete (must be the latest chapter; defaults to it)")
|
||||
.option("--force", "Skip confirmation prompt")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (bookIdArg: string, opts) => {
|
||||
try {
|
||||
const root = findProjectRoot();
|
||||
const bookId = await resolveBookId(bookIdArg, root);
|
||||
const state = new StateManager(root);
|
||||
const book = await state.loadBookConfig(bookId);
|
||||
const language = resolveCliLanguage(book.language);
|
||||
const requestedChapter = opts.chapter === undefined ? undefined : parseInt(opts.chapter, 10);
|
||||
|
||||
if (!opts.force) {
|
||||
const index = await state.loadChapterIndex(bookId);
|
||||
const latest = index.reduce((max, chapter) => Math.max(max, chapter.number), 0);
|
||||
const target = requestedChapter ?? latest;
|
||||
const title = index.find((chapter) => chapter.number === target)?.title ?? "";
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(
|
||||
formatChapterDeleteConfirm(language, { bookTitle: book.title, bookId, number: target, title }),
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
rl.close();
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
log(formatChapterDeleteCancelled(language));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await deleteLatestChapter(state, bookId, {
|
||||
...(requestedChapter === undefined ? {} : { chapterNumber: requestedChapter }),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
log(formatChapterDeleteDone(language, {
|
||||
number: result.deletedChapter,
|
||||
title: result.title,
|
||||
trashedFiles: result.trashedFiles,
|
||||
rolledBackTo: result.rolledBackTo,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
if (opts.json) {
|
||||
log(JSON.stringify({ error: String(e) }));
|
||||
} else {
|
||||
logError(`Failed to delete chapter: ${e}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -477,3 +477,35 @@ export function formatChapterSyncMissingFiles(language: CliLanguage, numbers: Re
|
||||
en: `Warning: chapter(s) ${numbers.join(", ")} exist in index.json but have no chapter file on disk; skipped.`,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatChapterDeleteConfirm(
|
||||
language: CliLanguage,
|
||||
params: { bookTitle: string; bookId: string; number: number; title: string },
|
||||
): string {
|
||||
return localize(language, {
|
||||
zh: `将删除《${params.bookTitle}》(${params.bookId}) 的最新章:第${params.number}章 ${params.title}。`
|
||||
+ `章节文件会移入 chapters/.trash/,索引和故事状态回滚到第${params.number - 1}章。确认删除?(y/N) `,
|
||||
en: `Delete the latest chapter of "${params.bookTitle}" (${params.bookId}): chapter ${params.number} ${params.title}? `
|
||||
+ `The chapter file moves to chapters/.trash/ and the index and story state roll back to chapter ${params.number - 1}. (y/N) `,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatChapterDeleteCancelled(language: CliLanguage): string {
|
||||
return localize(language, {
|
||||
zh: "已取消。",
|
||||
en: "Cancelled.",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatChapterDeleteDone(
|
||||
language: CliLanguage,
|
||||
params: { number: number; title: string; trashedFiles: ReadonlyArray<string>; rolledBackTo: number },
|
||||
): string {
|
||||
const trashNote = params.trashedFiles.length > 0
|
||||
? params.trashedFiles.join(", ")
|
||||
: localize(language, { zh: "(章节文件已不存在,未移动)", en: "(chapter file was already gone; nothing moved)" });
|
||||
return localize(language, {
|
||||
zh: `已删除第${params.number}章 ${params.title}:章节文件保留在 ${trashNote},索引和故事状态已回滚到第${params.rolledBackTo}章。`,
|
||||
en: `Deleted chapter ${params.number} ${params.title}: chapter file kept at ${trashNote}; index and story state rolled back to chapter ${params.rolledBackTo}.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { access, mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChapterMeta } from "../models/chapter.js";
|
||||
import { StateManager } from "../state/manager.js";
|
||||
import { deleteLatestChapter } from "../state/chapter-delete.js";
|
||||
|
||||
function chapterEntry(number: number, title: string): ChapterMeta {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
number,
|
||||
title,
|
||||
status: "ready-for-review",
|
||||
wordCount: 10,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
auditIssues: [],
|
||||
lengthWarnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
return access(path).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
async function setupBook(params: {
|
||||
readonly bookId: string;
|
||||
readonly chapters: ReadonlyArray<{ readonly number: number; readonly title: string; readonly content: string }>;
|
||||
readonly snapshotChapters: ReadonlyArray<number>;
|
||||
}): Promise<{ readonly root: string; readonly bookDir: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), "inkos-chapter-delete-"));
|
||||
const bookDir = join(root, "books", params.bookId);
|
||||
const storyDir = join(bookDir, "story");
|
||||
await mkdir(join(bookDir, "chapters"), { recursive: true });
|
||||
await mkdir(storyDir, { recursive: true });
|
||||
await writeFile(join(bookDir, "book.json"), JSON.stringify({ id: params.bookId, title: params.bookId }), "utf-8");
|
||||
|
||||
for (const chapter of params.chapters) {
|
||||
const padded = String(chapter.number).padStart(4, "0");
|
||||
await writeFile(join(bookDir, "chapters", `${padded}_${chapter.title}.md`), chapter.content, "utf-8");
|
||||
}
|
||||
await writeFile(
|
||||
join(bookDir, "chapters", "index.json"),
|
||||
JSON.stringify(params.chapters.map((c) => chapterEntry(c.number, c.title)), null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await writeFile(join(storyDir, "current_state.md"), "state after latest chapter", "utf-8");
|
||||
await writeFile(join(storyDir, "pending_hooks.md"), "hooks after latest chapter", "utf-8");
|
||||
for (const snapshotChapter of params.snapshotChapters) {
|
||||
const snapshotDir = join(storyDir, "snapshots", String(snapshotChapter));
|
||||
await mkdir(snapshotDir, { recursive: true });
|
||||
await writeFile(join(snapshotDir, "current_state.md"), `state at chapter ${snapshotChapter}`, "utf-8");
|
||||
await writeFile(join(snapshotDir, "pending_hooks.md"), `hooks at chapter ${snapshotChapter}`, "utf-8");
|
||||
}
|
||||
return { root, bookDir };
|
||||
}
|
||||
|
||||
describe("deleteLatestChapter", () => {
|
||||
it("moves the latest chapter file to chapters/.trash and rolls state back", async () => {
|
||||
const { root, bookDir } = await setupBook({
|
||||
bookId: "delbook",
|
||||
chapters: [
|
||||
{ number: 1, title: "起风", content: "# 第1章 起风\n\n第一章正文。" },
|
||||
{ number: 2, title: "落雨", content: "# 第2章 落雨\n\n第二章正文。" },
|
||||
{ number: 3, title: "收网", content: "# 第3章 收网\n\n第三章正文。" },
|
||||
],
|
||||
snapshotChapters: [1, 2, 3],
|
||||
});
|
||||
|
||||
const state = new StateManager(root);
|
||||
const result = await deleteLatestChapter(state, "delbook");
|
||||
|
||||
expect(result.deletedChapter).toBe(3);
|
||||
expect(result.title).toBe("收网");
|
||||
expect(result.rolledBackTo).toBe(2);
|
||||
expect(result.discarded).toEqual([3]);
|
||||
expect(result.trashedFiles).toEqual(["chapters/.trash/0003_收网.md"]);
|
||||
|
||||
// Chapter file is preserved in the trash, not hard-deleted.
|
||||
await expect(exists(join(bookDir, "chapters", "0003_收网.md"))).resolves.toBe(false);
|
||||
await expect(readFile(join(bookDir, "chapters", ".trash", "0003_收网.md"), "utf-8"))
|
||||
.resolves.toContain("第三章正文");
|
||||
|
||||
// Index drops the deleted chapter.
|
||||
const savedIndex = JSON.parse(await readFile(join(bookDir, "chapters", "index.json"), "utf-8")) as ChapterMeta[];
|
||||
expect(savedIndex.map((c) => c.number)).toEqual([1, 2]);
|
||||
|
||||
// Story state is rolled back to the chapter-2 snapshot.
|
||||
await expect(readFile(join(bookDir, "story", "current_state.md"), "utf-8"))
|
||||
.resolves.toBe("state at chapter 2");
|
||||
await expect(readFile(join(bookDir, "story", "pending_hooks.md"), "utf-8"))
|
||||
.resolves.toBe("hooks at chapter 2");
|
||||
});
|
||||
|
||||
it("rejects deleting a chapter that is not the latest", async () => {
|
||||
const { root, bookDir } = await setupBook({
|
||||
bookId: "midbook",
|
||||
chapters: [
|
||||
{ number: 1, title: "起风", content: "第一章。" },
|
||||
{ number: 2, title: "落雨", content: "第二章。" },
|
||||
],
|
||||
snapshotChapters: [1, 2],
|
||||
});
|
||||
|
||||
const state = new StateManager(root);
|
||||
await expect(deleteLatestChapter(state, "midbook", { chapterNumber: 1 }))
|
||||
.rejects.toThrow(/latest chapter/i);
|
||||
|
||||
// Nothing was touched.
|
||||
await expect(exists(join(bookDir, "chapters", "0001_起风.md"))).resolves.toBe(true);
|
||||
await expect(exists(join(bookDir, "chapters", ".trash"))).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects deleting from a book with no chapters", async () => {
|
||||
const { root } = await setupBook({
|
||||
bookId: "emptybook",
|
||||
chapters: [],
|
||||
snapshotChapters: [],
|
||||
});
|
||||
|
||||
const state = new StateManager(root);
|
||||
await expect(deleteLatestChapter(state, "emptybook"))
|
||||
.rejects.toThrow(/no chapters/i);
|
||||
});
|
||||
|
||||
it("fails before moving any file when the rollback snapshot is missing", async () => {
|
||||
const { root, bookDir } = await setupBook({
|
||||
bookId: "nosnapbook",
|
||||
chapters: [
|
||||
{ number: 1, title: "起风", content: "第一章。" },
|
||||
{ number: 2, title: "落雨", content: "第二章。" },
|
||||
],
|
||||
snapshotChapters: [2], // snapshot for chapter 1 (rollback target) is missing
|
||||
});
|
||||
|
||||
const state = new StateManager(root);
|
||||
await expect(deleteLatestChapter(state, "nosnapbook"))
|
||||
.rejects.toThrow(/snapshot/i);
|
||||
|
||||
// The chapter file stays in place — no half-deleted state.
|
||||
await expect(exists(join(bookDir, "chapters", "0002_落雨.md"))).resolves.toBe(true);
|
||||
await expect(exists(join(bookDir, "chapters", ".trash"))).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("deletes the only chapter of a book when the chapter-0 snapshot exists", async () => {
|
||||
const { root, bookDir } = await setupBook({
|
||||
bookId: "onebook",
|
||||
chapters: [{ number: 1, title: "起风", content: "第一章正文。" }],
|
||||
snapshotChapters: [0, 1],
|
||||
});
|
||||
|
||||
const state = new StateManager(root);
|
||||
const result = await deleteLatestChapter(state, "onebook");
|
||||
|
||||
expect(result.deletedChapter).toBe(1);
|
||||
expect(result.rolledBackTo).toBe(0);
|
||||
const savedIndex = JSON.parse(await readFile(join(bookDir, "chapters", "index.json"), "utf-8")) as ChapterMeta[];
|
||||
expect(savedIndex).toEqual([]);
|
||||
await expect(readFile(join(bookDir, "chapters", ".trash", "0001_起风.md"), "utf-8"))
|
||||
.resolves.toBe("第一章正文。");
|
||||
});
|
||||
|
||||
it("keeps existing trash entries by picking a distinct name on collision", async () => {
|
||||
const { root, bookDir } = await setupBook({
|
||||
bookId: "twicebook",
|
||||
chapters: [
|
||||
{ number: 1, title: "起风", content: "第一章。" },
|
||||
{ number: 2, title: "落雨", content: "新的第二章。" },
|
||||
],
|
||||
snapshotChapters: [1, 2],
|
||||
});
|
||||
await mkdir(join(bookDir, "chapters", ".trash"), { recursive: true });
|
||||
await writeFile(join(bookDir, "chapters", ".trash", "0002_落雨.md"), "旧的第二章。", "utf-8");
|
||||
|
||||
const state = new StateManager(root);
|
||||
const result = await deleteLatestChapter(state, "twicebook");
|
||||
|
||||
expect(result.trashedFiles).toEqual(["chapters/.trash/0002_落雨-2.md"]);
|
||||
await expect(readFile(join(bookDir, "chapters", ".trash", "0002_落雨.md"), "utf-8"))
|
||||
.resolves.toBe("旧的第二章。");
|
||||
await expect(readFile(join(bookDir, "chapters", ".trash", "0002_落雨-2.md"), "utf-8"))
|
||||
.resolves.toBe("新的第二章。");
|
||||
});
|
||||
|
||||
it("rolls back the index even when the chapter file was already deleted by hand", async () => {
|
||||
const { root, bookDir } = await setupBook({
|
||||
bookId: "handbook",
|
||||
chapters: [
|
||||
{ number: 1, title: "起风", content: "第一章。" },
|
||||
{ number: 2, title: "落雨", content: "第二章。" },
|
||||
],
|
||||
snapshotChapters: [1, 2],
|
||||
});
|
||||
const { rm } = await import("node:fs/promises");
|
||||
await rm(join(bookDir, "chapters", "0002_落雨.md"));
|
||||
|
||||
const state = new StateManager(root);
|
||||
const result = await deleteLatestChapter(state, "handbook");
|
||||
|
||||
expect(result.deletedChapter).toBe(2);
|
||||
expect(result.trashedFiles).toEqual([]);
|
||||
const savedIndex = JSON.parse(await readFile(join(bookDir, "chapters", "index.json"), "utf-8")) as ChapterMeta[];
|
||||
expect(savedIndex.map((c) => c.number)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("ignores files inside chapters/.trash when resolving the latest chapter's files", async () => {
|
||||
const { root, bookDir } = await setupBook({
|
||||
bookId: "trashscanbook",
|
||||
chapters: [
|
||||
{ number: 1, title: "起风", content: "第一章。" },
|
||||
{ number: 2, title: "落雨", content: "第二章。" },
|
||||
],
|
||||
snapshotChapters: [1, 2],
|
||||
});
|
||||
await mkdir(join(bookDir, "chapters", ".trash"), { recursive: true });
|
||||
await writeFile(join(bookDir, "chapters", ".trash", "0009_幽灵.md"), "trash ghost", "utf-8");
|
||||
|
||||
const state = new StateManager(root);
|
||||
const result = await deleteLatestChapter(state, "trashscanbook");
|
||||
|
||||
expect(result.deletedChapter).toBe(2);
|
||||
const trashEntries = await readdir(join(bookDir, "chapters", ".trash"));
|
||||
expect(trashEntries.sort()).toEqual(["0002_落雨.md", "0009_幽灵.md"]);
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,32 @@ describe("edit controller", () => {
|
||||
expect(result.touchedFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not rewrite trashed chapters during entity rename", async () => {
|
||||
const bookDir = join(projectRoot, "books", "trashbook");
|
||||
await mkdir(join(bookDir, "story"), { recursive: true });
|
||||
await mkdir(join(bookDir, "chapters", ".trash"), { recursive: true });
|
||||
await writeFile(join(bookDir, "story", "story_bible.md"), "主角陆尘住在港口。", "utf-8");
|
||||
await writeFile(join(bookDir, "chapters", ".trash", "0009_旧章.md"), "陆尘在被删除的章节里。", "utf-8");
|
||||
|
||||
await executeEditTransaction(
|
||||
{
|
||||
bookDir: (bookId) => join(projectRoot, "books", bookId),
|
||||
loadChapterIndex: async () => [],
|
||||
saveChapterIndex: async () => undefined,
|
||||
},
|
||||
{
|
||||
kind: "entity-rename",
|
||||
bookId: "trashbook",
|
||||
entityType: "protagonist",
|
||||
oldValue: "陆尘",
|
||||
newValue: "林砚",
|
||||
},
|
||||
);
|
||||
|
||||
await expect(readFile(join(bookDir, "story", "story_bible.md"), "utf-8")).resolves.toContain("林砚");
|
||||
await expect(readFile(join(bookDir, "chapters", ".trash", "0009_旧章.md"), "utf-8")).resolves.toContain("陆尘");
|
||||
});
|
||||
|
||||
it("does not rewrite story snapshots during entity rename", async () => {
|
||||
const bookDir = join(projectRoot, "books", "harbor");
|
||||
await writeFile(join(bookDir, "story", "story_bible.md"), "主角陆尘住在港口。", "utf-8");
|
||||
|
||||
@@ -510,6 +510,7 @@ export { ScriptCreationAgent, StoryboardCreationAgent, InteractiveFilmCreationAg
|
||||
// State
|
||||
export { BookWriteLockError, StateManager } from "./state/manager.js";
|
||||
export { syncChapterWordCounts, type ChapterWordCountChange, type ChapterWordSyncDeps, type ChapterWordSyncResult } from "./state/chapter-word-sync.js";
|
||||
export { deleteLatestChapter, type ChapterDeleteDeps, type DeleteLatestChapterOptions, type DeleteLatestChapterResult } from "./state/chapter-delete.js";
|
||||
export { bootstrapStructuredStateFromMarkdown } from "./state/state-bootstrap.js";
|
||||
export { renderCurrentStateProjection, renderHooksProjection, renderChapterSummariesProjection } from "./state/state-projections.js";
|
||||
export { applyRuntimeStateDelta, type RuntimeStateSnapshot } from "./state/state-reducer.js";
|
||||
|
||||
@@ -146,7 +146,9 @@ async function collectEditableFiles(dir: string): Promise<ReadonlyArray<string>>
|
||||
const files = await Promise.all(entries.map(async (entry) => {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "snapshots") return [];
|
||||
// Snapshots are frozen history; dot-directories (e.g. chapters/.trash)
|
||||
// hold discarded content — neither may be rewritten by edits.
|
||||
if (entry.name === "snapshots" || entry.name.startsWith(".")) return [];
|
||||
return collectEditableFiles(fullPath);
|
||||
}
|
||||
if (!/\.(md|json|ya?ml|txt)$/i.test(entry.name)) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { access, mkdir, readdir, rename, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { ChapterMeta } from "../models/chapter.js";
|
||||
import { toPosixPath } from "../utils/posix-path.js";
|
||||
|
||||
export interface ChapterDeleteDeps {
|
||||
bookDir(bookId: string): string;
|
||||
loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>>;
|
||||
rollbackToChapter(bookId: string, targetChapter: number): Promise<ReadonlyArray<number>>;
|
||||
}
|
||||
|
||||
export interface DeleteLatestChapterOptions {
|
||||
/** Must equal the latest chapter number; defaults to it. Middle chapters are not deletable. */
|
||||
readonly chapterNumber?: number;
|
||||
}
|
||||
|
||||
export interface DeleteLatestChapterResult {
|
||||
readonly bookId: string;
|
||||
readonly deletedChapter: number;
|
||||
readonly title: string;
|
||||
/** Book-relative POSIX paths of chapter files preserved under chapters/.trash/. */
|
||||
readonly trashedFiles: ReadonlyArray<string>;
|
||||
readonly rolledBackTo: number;
|
||||
readonly discarded: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the latest chapter of a book: the chapter markdown is preserved under
|
||||
* chapters/.trash/ (never hard-deleted), then the index, snapshots, runtime
|
||||
* artifacts, and story state are rolled back to the previous chapter via the
|
||||
* same rollback mechanism the review-reject flow uses.
|
||||
*
|
||||
* Only the latest chapter is deletable — removing a middle chapter would
|
||||
* require renumbering every later chapter and replaying state on top of it.
|
||||
*/
|
||||
export async function deleteLatestChapter(
|
||||
deps: ChapterDeleteDeps,
|
||||
bookId: string,
|
||||
options: DeleteLatestChapterOptions = {},
|
||||
): Promise<DeleteLatestChapterResult> {
|
||||
const index = await deps.loadChapterIndex(bookId);
|
||||
if (index.length === 0) {
|
||||
throw new Error(`Book "${bookId}" has no chapters to delete.`);
|
||||
}
|
||||
|
||||
const latest = index.reduce((max, chapter) => Math.max(max, chapter.number), 0);
|
||||
const requested = options.chapterNumber ?? latest;
|
||||
if (requested !== latest) {
|
||||
throw new Error(
|
||||
`Only the latest chapter (${latest}) can be deleted, but chapter ${requested} was requested. `
|
||||
+ "Deleting a middle chapter would require renumbering later chapters and replaying state.",
|
||||
);
|
||||
}
|
||||
|
||||
const bookDir = deps.bookDir(bookId);
|
||||
const rollbackTarget = latest - 1;
|
||||
|
||||
// Verify the rollback snapshot is usable BEFORE touching any file, so a
|
||||
// failed restore cannot leave the book half-deleted.
|
||||
for (const required of ["current_state.md", "pending_hooks.md"]) {
|
||||
const snapshotFile = join(bookDir, "story", "snapshots", String(rollbackTarget), required);
|
||||
try {
|
||||
await stat(snapshotFile);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Cannot delete chapter ${latest}: the state snapshot for chapter ${rollbackTarget} is missing `
|
||||
+ `(story/snapshots/${rollbackTarget}/${required}). Nothing was changed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the chapter markdown in chapters/.trash/ instead of hard-deleting.
|
||||
const chaptersDir = join(bookDir, "chapters");
|
||||
const trashDir = join(chaptersDir, ".trash");
|
||||
const chapterFiles = (await readdir(chaptersDir)).filter((file) => {
|
||||
const match = file.match(/^(\d+)[_-]?.*\.md$/);
|
||||
return match !== null && parseInt(match[1]!, 10) === latest;
|
||||
});
|
||||
|
||||
const trashedFiles: string[] = [];
|
||||
if (chapterFiles.length > 0) {
|
||||
await mkdir(trashDir, { recursive: true });
|
||||
}
|
||||
for (const file of chapterFiles) {
|
||||
const trashedName = await pickAvailableName(trashDir, file);
|
||||
await rename(join(chaptersDir, file), join(trashDir, trashedName));
|
||||
trashedFiles.push(toPosixPath(join("chapters", ".trash", trashedName)));
|
||||
}
|
||||
|
||||
const discarded = await deps.rollbackToChapter(bookId, rollbackTarget);
|
||||
const entry = index.find((chapter) => chapter.number === latest);
|
||||
|
||||
return {
|
||||
bookId,
|
||||
deletedChapter: latest,
|
||||
title: entry?.title ?? `第${latest}章`,
|
||||
trashedFiles,
|
||||
rolledBackTo: rollbackTarget,
|
||||
discarded,
|
||||
};
|
||||
}
|
||||
|
||||
async function pickAvailableName(dir: string, fileName: string): Promise<string> {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
const base = dot === -1 ? fileName : fileName.slice(0, dot);
|
||||
const ext = dot === -1 ? "" : fileName.slice(dot);
|
||||
let candidate = fileName;
|
||||
for (let suffix = 2; await pathExists(join(dir, candidate)); suffix += 1) {
|
||||
candidate = `${base}-${suffix}${ext}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
return access(path).then(() => true).catch(() => false);
|
||||
}
|
||||
Reference in New Issue
Block a user