diff --git a/package.json b/package.json index 9279564..d63b0e7 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "phase-script": "tsx src/cli/phase-demo.ts --script --auto", "web": "tsx src/server/web-server.ts", "web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts", - "electron": "electron ." + "electron": "electron .", + "libraries:restore": "tsx src/cli/restore-library-seeds.ts", + "libraries:export": "tsx src/cli/restore-library-seeds.ts --export" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/seeds/libraries/README.md b/seeds/libraries/README.md new file mode 100644 index 0000000..5d37332 --- /dev/null +++ b/seeds/libraries/README.md @@ -0,0 +1,18 @@ +# 库种子(git 备份) + +本目录保存偏好库、文风库的可提交种子,便于数据丢失后恢复。 + +- `preferences.json` / `style-packs.json`:与用户目录中同名文件同形(`version: 1` + `entries`)。 +- 运行时库仍在用户数据目录;本目录只作仓库备份与恢复源。 + +恢复(合并写入,不覆盖已有同 id 正文): + +```bash +npx tsx src/cli/restore-library-seeds.ts +``` + +导出当前用户库到本目录(覆盖种子文件,便于更新 git 备份): + +```bash +npx tsx src/cli/restore-library-seeds.ts --export +``` diff --git a/seeds/libraries/preferences.json b/seeds/libraries/preferences.json new file mode 100644 index 0000000..d114312 --- /dev/null +++ b/seeds/libraries/preferences.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "entries": [ + { + "id": "starter-no-user-ntr-victim", + "content": "用户代入角色不做被 NTR 的一方(恋人/伴侣与第三者发生性或背叛性感情,用户角色受害或被欺瞒)。用户角色 NTR 别人不受此限。", + "status": "active", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z" + }, + { + "id": "starter-default-male-hetero-yuri-ok", + "content": "禁止对男同性恋内容的直接描写,减少或忽略间接描写。默认用户角色为男性,情爱与性吸引朝向女性;女同性恋内容可写。", + "status": "active", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z" + } + ] +} diff --git a/seeds/libraries/style-packs.json b/seeds/libraries/style-packs.json new file mode 100644 index 0000000..7f19696 --- /dev/null +++ b/seeds/libraries/style-packs.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "entries": [] +} diff --git a/src/cli/restore-library-seeds.ts b/src/cli/restore-library-seeds.ts new file mode 100644 index 0000000..6828ea2 --- /dev/null +++ b/src/cli/restore-library-seeds.ts @@ -0,0 +1,24 @@ +/** + * 从仓库 seeds/libraries 恢复偏好库/文风库,或导出当前库到种子目录。 + * + * npx tsx src/cli/restore-library-seeds.ts + * npx tsx src/cli/restore-library-seeds.ts --export + */ +import { + exportLibrarySeedsToRepo, + restoreLibrarySeedsFromRepo, +} from "../libraries/seed-backup.js"; + +const exportMode = process.argv.includes("--export"); + +if (exportMode) { + const result = exportLibrarySeedsToRepo(); + console.log( + `已导出种子:preferences=${result.preferences} style-packs=${result.stylePacks}`, + ); +} else { + const result = restoreLibrarySeedsFromRepo(); + console.log( + `已合并恢复:preferences +${result.preferencesAdded} style-packs +${result.stylePacksAdded}`, + ); +} diff --git a/src/libraries/seed-backup.ts b/src/libraries/seed-backup.ts new file mode 100644 index 0000000..a9a896c --- /dev/null +++ b/src/libraries/seed-backup.ts @@ -0,0 +1,113 @@ +/** + * 仓库内库种子 ↔ 用户数据目录库文件的导入/导出。 + * 导入:缺 id 才补,不覆盖用户已改同 id 正文。 + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + loadPreferenceStore, + savePreferenceStore, + type PreferenceStoreFile, +} from "../preference/store.js"; +import { + loadStylePackStore, + saveStylePackStore, + type StylePackStoreFile, +} from "../style-pack/store.js"; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); + +export const LIBRARY_SEED_DIR = path.join(ROOT, "seeds", "libraries"); + +function readJsonFile(file: string): unknown { + return JSON.parse(readFileSync(file, "utf8")) as unknown; +} + +function isPreferenceStore(raw: unknown): raw is PreferenceStoreFile { + return ( + !!raw && + typeof raw === "object" && + !Array.isArray(raw) && + (raw as PreferenceStoreFile).version === 1 && + Array.isArray((raw as PreferenceStoreFile).entries) + ); +} + +function isStylePackStore(raw: unknown): raw is StylePackStoreFile { + return ( + !!raw && + typeof raw === "object" && + !Array.isArray(raw) && + (raw as StylePackStoreFile).version === 1 && + Array.isArray((raw as StylePackStoreFile).entries) + ); +} + +export function restoreLibrarySeedsFromRepo(): { + preferencesAdded: number; + stylePacksAdded: number; +} { + const prefPath = path.join(LIBRARY_SEED_DIR, "preferences.json"); + const stylePath = path.join(LIBRARY_SEED_DIR, "style-packs.json"); + let preferencesAdded = 0; + let stylePacksAdded = 0; + + if (existsSync(prefPath)) { + const seed = readJsonFile(prefPath); + if (isPreferenceStore(seed)) { + const store = loadPreferenceStore(); + const have = new Set(store.entries.map((entry) => entry.id)); + for (const entry of seed.entries) { + if (!entry.id || have.has(entry.id)) continue; + store.entries.push(entry); + have.add(entry.id); + preferencesAdded += 1; + } + if (preferencesAdded) savePreferenceStore(store); + } + } + + if (existsSync(stylePath)) { + const seed = readJsonFile(stylePath); + if (isStylePackStore(seed)) { + const store = loadStylePackStore(); + const have = new Set(store.entries.map((entry) => entry.id)); + for (const entry of seed.entries) { + if (!entry.id || have.has(entry.id)) continue; + store.entries.push(entry); + have.add(entry.id); + stylePacksAdded += 1; + } + if (stylePacksAdded) saveStylePackStore(store); + } + } + + return { preferencesAdded, stylePacksAdded }; +} + +export function exportLibrarySeedsToRepo(): { + preferences: number; + stylePacks: number; +} { + mkdirSync(LIBRARY_SEED_DIR, { recursive: true }); + const preferences = loadPreferenceStore(); + const stylePacks = loadStylePackStore(); + writeFileSync( + path.join(LIBRARY_SEED_DIR, "preferences.json"), + `${JSON.stringify(preferences, null, 2)}\n`, + "utf8", + ); + writeFileSync( + path.join(LIBRARY_SEED_DIR, "style-packs.json"), + `${JSON.stringify(stylePacks, null, 2)}\n`, + "utf8", + ); + return { + preferences: preferences.entries.length, + stylePacks: stylePacks.entries.length, + }; +} diff --git a/tests/library-seed-backup.test.ts b/tests/library-seed-backup.test.ts new file mode 100644 index 0000000..1ad3138 --- /dev/null +++ b/tests/library-seed-backup.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + LIBRARY_SEED_DIR, + restoreLibrarySeedsFromRepo, +} from "../src/libraries/seed-backup.js"; +import { + listActivePreferences, + loadPreferenceStore, + savePreferenceStore, +} from "../src/preference/store.js"; + +const originalUserData = process.env.WRITING_AGENT_DATA_DIR; + +describe("library seed backup", () => { + let tempDir = ""; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(os.tmpdir(), "wa-lib-seed-")); + process.env.WRITING_AGENT_DATA_DIR = tempDir; + }); + + afterEach(() => { + if (originalUserData === undefined) { + delete process.env.WRITING_AGENT_DATA_DIR; + } else { + process.env.WRITING_AGENT_DATA_DIR = originalUserData; + } + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("restores missing preference ids from repo seeds without overwriting", () => { + savePreferenceStore({ + version: 1, + entries: [ + { + id: "starter-no-user-ntr-victim", + content: "用户已改过的正文", + status: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }); + + const result = restoreLibrarySeedsFromRepo(); + expect(result.preferencesAdded).toBeGreaterThanOrEqual(1); + + const store = loadPreferenceStore(); + const kept = store.entries.find((e) => e.id === "starter-no-user-ntr-victim"); + expect(kept?.content).toBe("用户已改过的正文"); + expect( + store.entries.some((e) => e.id === "starter-default-male-hetero-yuri-ok"), + ).toBe(true); + expect(listActivePreferences().length).toBeGreaterThanOrEqual(2); + }); + + it("keeps seed files in the repository libraries directory", () => { + const prefSeed = path.join(LIBRARY_SEED_DIR, "preferences.json"); + const raw = JSON.parse(readFileSync(prefSeed, "utf8")) as { + version: number; + entries: Array<{ id: string }>; + }; + expect(raw.version).toBe(1); + expect(raw.entries.some((e) => e.id === "starter-no-user-ntr-victim")).toBe( + true, + ); + }); +}); \ No newline at end of file