@musnows/scriverse 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.js CHANGED
@@ -16,7 +16,7 @@ import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSec
16
16
  import { normalizeCharacterAttributes, normalizeCharacterState } from "./public/character-profile.js";
17
17
  import { normalizeRoleplayScenePin, roleplayUserTurnDisplayText, roleplayUserTurnTitleSource } from "./roleplay-turn.js";
18
18
  import { chapterAnnotationLineHashes, createChapterLineIds, MAX_CHAPTER_LINE_IDS, parseChapterAnnotationLineIds, parseChapterAnnotationLineHashes, parseChapterLineIds, reconcileChapterLineIds, reanchorChapterAnnotations } from "./chapter-annotation-anchor.js";
19
- import { renumberChapterTitle } from "./chapter-title-numbering.js";
19
+ import { chapterTitleFormats, isChapterTitlePreference, parseChapterTitleNumber, planAutoNumberedTitles, planVolumeTitleRenumber, renumberChapterTitle, resolveChapterTitleFormat } from "./chapter-title-numbering.js";
20
20
  import { normalizeRoleplayMemoryContent, roleplayMemoryCandidateIsSafe } from "./roleplay-memory.js";
21
21
  const WORK_LIST_BATCH_SIZE = 500;
22
22
  const ENTITY_LIST_BATCH_SIZE = 400;
@@ -578,6 +578,7 @@ export class Store {
578
578
  tags: entity.tags,
579
579
  editorAutoIndentEnabled: entity.editorAutoIndentEnabled,
580
580
  editorTypewriterModeEnabled: entity.editorTypewriterModeEnabled,
581
+ chapterTitleFormat: entity.chapterTitleFormat,
581
582
  ownerUserId: entity.ownerUserId
582
583
  };
583
584
  if (type === "volume")
@@ -847,8 +848,8 @@ export class Store {
847
848
  const ownerUserId = this.resolveWorkOwnerUserId(typeof snapshot.ownerUserId === "string" ? snapshot.ownerUserId : null, true);
848
849
  const timestamp = now();
849
850
  this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
850
- editor_auto_indent_enabled, editor_typewriter_mode_enabled, version_no, created_at, updated_at, owner_user_id)
851
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, entityId, String(snapshot.title ?? "未命名作品"), String(snapshot.author ?? ""), String(snapshot.description ?? ""), String(snapshot.language ?? "zh-CN"), snapshot.coverUrl ?? null, JSON.stringify(Array.isArray(snapshot.tags) ? snapshot.tags : []), snapshot.editorAutoIndentEnabled === true ? 1 : 0, snapshot.editorTypewriterModeEnabled === true ? 1 : 0, timestamp, timestamp, ownerUserId);
851
+ editor_auto_indent_enabled, editor_typewriter_mode_enabled, chapter_title_format, version_no, created_at, updated_at, owner_user_id)
852
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, entityId, String(snapshot.title ?? "未命名作品"), String(snapshot.author ?? ""), String(snapshot.description ?? ""), String(snapshot.language ?? "zh-CN"), snapshot.coverUrl ?? null, JSON.stringify(Array.isArray(snapshot.tags) ? snapshot.tags : []), snapshot.editorAutoIndentEnabled === true ? 1 : 0, snapshot.editorTypewriterModeEnabled === true ? 1 : 0, isChapterTitlePreference(String(snapshot.chapterTitleFormat ?? "")) ? String(snapshot.chapterTitleFormat) : "off", timestamp, timestamp, ownerUserId);
852
853
  if (ownerUserId !== SYSTEM_USER_ID) {
853
854
  this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", entityId, ownerUserId, ownerUserId, timestamp);
854
855
  }
@@ -912,10 +913,12 @@ export class Store {
912
913
  const workId = id("work");
913
914
  const timestamp = now();
914
915
  const resolvedOwnerUserId = this.resolveWorkOwnerUserId(ownerUserId);
916
+ const requestedTitleFormat = input.chapterTitleFormat ?? "";
917
+ const chapterTitleFormat = isChapterTitlePreference(requestedTitleFormat) ? requestedTitleFormat : "off";
915
918
  this.db.transaction(() => {
916
919
  this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
917
- editor_auto_indent_enabled, editor_typewriter_mode_enabled, created_at, updated_at, owner_user_id)
918
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), input.editorAutoIndentEnabled === true ? 1 : 0, input.editorTypewriterModeEnabled === true ? 1 : 0, timestamp, timestamp, resolvedOwnerUserId);
920
+ editor_auto_indent_enabled, editor_typewriter_mode_enabled, chapter_title_format, created_at, updated_at, owner_user_id)
921
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), input.editorAutoIndentEnabled === true ? 1 : 0, input.editorTypewriterModeEnabled === true ? 1 : 0, chapterTitleFormat, timestamp, timestamp, resolvedOwnerUserId);
919
922
  if (resolvedOwnerUserId !== SYSTEM_USER_ID) {
920
923
  this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", workId, resolvedOwnerUserId, resolvedOwnerUserId, timestamp);
921
924
  }
@@ -1356,8 +1359,8 @@ export class Store {
1356
1359
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
1357
1360
  const timestamp = now();
1358
1361
  this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?,
1359
- editor_auto_indent_enabled = ?, editor_typewriter_mode_enabled = ?, version_no = version_no + 1, updated_at = ?
1360
- WHERE id = ?`, input.title ?? String(current.title), input.author ?? String(current.author), input.description ?? String(current.description), input.language ?? String(current.language), input.coverUrl === undefined ? current.coverUrl : input.coverUrl, JSON.stringify(input.tags ?? current.tags), input.editorAutoIndentEnabled === undefined ? (current.editorAutoIndentEnabled ? 1 : 0) : input.editorAutoIndentEnabled ? 1 : 0, input.editorTypewriterModeEnabled === undefined ? (current.editorTypewriterModeEnabled ? 1 : 0) : input.editorTypewriterModeEnabled ? 1 : 0, timestamp, workId);
1362
+ editor_auto_indent_enabled = ?, editor_typewriter_mode_enabled = ?, chapter_title_format = ?, version_no = version_no + 1, updated_at = ?
1363
+ WHERE id = ?`, input.title ?? String(current.title), input.author ?? String(current.author), input.description ?? String(current.description), input.language ?? String(current.language), input.coverUrl === undefined ? current.coverUrl : input.coverUrl, JSON.stringify(input.tags ?? current.tags), input.editorAutoIndentEnabled === undefined ? (current.editorAutoIndentEnabled ? 1 : 0) : input.editorAutoIndentEnabled ? 1 : 0, input.editorTypewriterModeEnabled === undefined ? (current.editorTypewriterModeEnabled ? 1 : 0) : input.editorTypewriterModeEnabled ? 1 : 0, input.chapterTitleFormat === undefined ? String(current.chapterTitleFormat ?? "off") : input.chapterTitleFormat, timestamp, workId);
1361
1364
  this.recordEntityVersion("work", workId, source, sourceRef, changeNote || "更新作品信息", timestamp);
1362
1365
  this.audit(workId, "work.updated", "work", workId, { fields: Object.keys(input), versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
1363
1366
  });
@@ -2081,16 +2084,190 @@ export class Store {
2081
2084
  }
2082
2085
  createChapter(workId, input) {
2083
2086
  return this.db.transaction(() => {
2087
+ if (input.insertAfterChapterId && input.insertBeforeChapterId) {
2088
+ throw new AppError(400, "CHAPTER_INSERT_ANCHOR_CONFLICT", "不能同时指定章节前后插入位置");
2089
+ }
2084
2090
  this.getWork(workId);
2085
2091
  const volume = this.getVolume(input.volumeId);
2086
2092
  if (volume.workId !== workId)
2087
2093
  throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
2088
- const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", input.volumeId);
2089
- const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
2090
- this.audit(workId, "chapter.created", "chapter", chapterId);
2094
+ const timestamp = now();
2095
+ const rows = this.listActiveChapterRows(workId, input.volumeId);
2096
+ let sortOrder = numberValue(this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", input.volumeId) ?? {}, "value") + 1;
2097
+ let insertIndex = rows.length;
2098
+ const anchorId = input.insertBeforeChapterId ?? input.insertAfterChapterId;
2099
+ if (anchorId) {
2100
+ const anchor = this.getChapter(anchorId);
2101
+ if (anchor.workId !== workId)
2102
+ throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
2103
+ const anchorIndex = String(anchor.volumeId) === input.volumeId
2104
+ ? rows.findIndex((row) => requiredString(row, "id") === anchorId)
2105
+ : -1;
2106
+ if (anchorIndex >= 0) {
2107
+ const before = Boolean(input.insertBeforeChapterId);
2108
+ sortOrder = Number(anchor.sortOrder) + (before ? 0 : 1);
2109
+ insertIndex = anchorIndex + (before ? 0 : 1);
2110
+ this.db.run("UPDATE chapters SET sort_order = sort_order + 1, updated_at = ? WHERE volume_id = ? AND deleted_at IS NULL AND sort_order >= ?", timestamp, input.volumeId, sortOrder);
2111
+ }
2112
+ }
2113
+ const pendingId = "__pending_chapter__";
2114
+ const ordered = [
2115
+ ...rows.slice(0, insertIndex).map((row) => ({ id: requiredString(row, "id"), title: requiredString(row, "title") })),
2116
+ { id: pendingId, title: input.title },
2117
+ ...rows.slice(insertIndex).map((row) => ({ id: requiredString(row, "id"), title: requiredString(row, "title") }))
2118
+ ];
2119
+ const formatId = input.numberTitle ? this.resolveWorkChapterTitleFormat(workId, ordered.map((chapter) => chapter.title)) : null;
2120
+ let title = input.title;
2121
+ let numberingUpdates = [];
2122
+ if (formatId) {
2123
+ try {
2124
+ numberingUpdates = planAutoNumberedTitles(ordered, pendingId, input.title, formatId);
2125
+ }
2126
+ catch (error) {
2127
+ this.rethrowChapterNumberError(error);
2128
+ }
2129
+ title = numberingUpdates.find((item) => item.id === pendingId)?.title ?? input.title;
2130
+ numberingUpdates = numberingUpdates.filter((item) => item.id !== pendingId);
2131
+ if (title.length > 300)
2132
+ throw new AppError(400, "CHAPTER_TITLE_TOO_LONG", "编号后的章节标题超过 300 个字符");
2133
+ }
2134
+ const chapterId = this.insertChapter(workId, input.volumeId, title, input.content ?? "", sortOrder, "manual", null, input.chapterType ?? "正文");
2135
+ if (numberingUpdates.length) {
2136
+ const shiftedRows = this.listActiveChapterRows(workId, input.volumeId);
2137
+ this.writeChapterTitleUpdates(workId, numberingUpdates, shiftedRows, "自动顺延章节序号", timestamp);
2138
+ }
2139
+ this.audit(workId, "chapter.created", "chapter", chapterId, formatId ? { chapterTitleFormat: formatId, numbered: numberingUpdates.length > 0 || title !== input.title } : {});
2140
+ return this.getChapter(chapterId);
2141
+ });
2142
+ }
2143
+ describeChapterTitleFormat(workId) {
2144
+ const work = this.getWork(workId);
2145
+ const preference = isChapterTitlePreference(String(work.chapterTitleFormat ?? ""))
2146
+ ? String(work.chapterTitleFormat)
2147
+ : "off";
2148
+ const detected = resolveChapterTitleFormat("auto", this.listActiveChapterRows(workId).map((row) => requiredString(row, "title")));
2149
+ return {
2150
+ preference,
2151
+ detected,
2152
+ resolved: resolveChapterTitleFormat(preference, this.listActiveChapterRows(workId).map((row) => requiredString(row, "title")))
2153
+ };
2154
+ }
2155
+ applyChapterTitleNumber(chapterId, title, expectedVersionNo) {
2156
+ return this.db.transaction(() => {
2157
+ const current = this.getChapter(chapterId);
2158
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(current.versionNo));
2159
+ const workId = String(current.workId);
2160
+ const volumeId = String(current.volumeId);
2161
+ const rows = this.listActiveChapterRows(workId, volumeId);
2162
+ const formatId = this.resolveWorkChapterTitleFormat(workId, rows.map((row) => requiredString(row, "title")));
2163
+ const savedNumber = parseChapterTitleNumber(String(current.title));
2164
+ if (!formatId || parseChapterTitleNumber(title) || savedNumber) {
2165
+ const nextTitle = formatId && savedNumber && !parseChapterTitleNumber(title)
2166
+ ? renumberChapterTitle(title, savedNumber.number, chapterTitleFormats[savedNumber.formatId].template, chapterTitleFormats[savedNumber.formatId].style)
2167
+ : title;
2168
+ return this.saveChapter(chapterId, { title: nextTitle }, "manual", null, nextTitle === title ? "" : "补回章节序号", expectedVersionNo);
2169
+ }
2170
+ const ordered = rows.map((row) => ({
2171
+ id: requiredString(row, "id"),
2172
+ title: requiredString(row, "id") === chapterId ? title : requiredString(row, "title")
2173
+ }));
2174
+ let updates;
2175
+ try {
2176
+ updates = planAutoNumberedTitles(ordered, chapterId, title, formatId);
2177
+ }
2178
+ catch (error) {
2179
+ this.rethrowChapterNumberError(error);
2180
+ }
2181
+ const timestamp = now();
2182
+ this.writeChapterTitleUpdates(workId, updates, rows, "补写章节序号", timestamp);
2183
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
2091
2184
  return this.getChapter(chapterId);
2092
2185
  });
2093
2186
  }
2187
+ renumberVolumeChapterTitles(volumeId) {
2188
+ return this.db.transaction(() => {
2189
+ const volume = this.getVolume(volumeId);
2190
+ const workId = String(volume.workId);
2191
+ const rows = this.listActiveChapterRows(workId, volumeId);
2192
+ const formatId = this.resolveWorkChapterTitleFormat(workId, rows.map((row) => requiredString(row, "title")));
2193
+ if (!formatId)
2194
+ throw new AppError(400, "CHAPTER_TITLE_FORMAT_REQUIRED", "请先在目录的标题编号中选择格式,或使用自动识别");
2195
+ let plan;
2196
+ try {
2197
+ plan = planVolumeTitleRenumber(rows.map((row) => ({ id: requiredString(row, "id"), title: requiredString(row, "title") })), formatId);
2198
+ }
2199
+ catch (error) {
2200
+ this.rethrowChapterNumberError(error);
2201
+ }
2202
+ const timestamp = now();
2203
+ const updated = this.writeChapterTitleUpdates(workId, plan.updates, rows, "按分卷重排章节标题序号", timestamp);
2204
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
2205
+ this.audit(workId, "chapter.volume-renumbered", "volume", volumeId, {
2206
+ format: formatId,
2207
+ startAt: plan.startAt,
2208
+ updated,
2209
+ chapterCount: rows.length
2210
+ });
2211
+ return { volumeId, updated, format: formatId, startAt: plan.startAt };
2212
+ });
2213
+ }
2214
+ resolveWorkChapterTitleFormat(workId, titles) {
2215
+ const preference = String(this.getWork(workId).chapterTitleFormat ?? "off");
2216
+ return resolveChapterTitleFormat(preference, titles);
2217
+ }
2218
+ listActiveChapterRows(workId, volumeId) {
2219
+ return this.db.all(`SELECT chapter.* FROM chapters chapter
2220
+ JOIN volumes volume ON volume.id = chapter.volume_id
2221
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
2222
+ ${volumeId ? "AND chapter.volume_id = ?" : ""}
2223
+ ORDER BY volume.sort_order, volume.created_at, volume.id,
2224
+ chapter.sort_order, chapter.created_at, chapter.id`, ...(volumeId ? [workId, volumeId] : [workId]));
2225
+ }
2226
+ rethrowChapterNumberError(error) {
2227
+ if (error instanceof RangeError)
2228
+ throw new AppError(400, "CHAPTER_NUMBER_RANGE", "章节序号超出 1 到 999999");
2229
+ throw error;
2230
+ }
2231
+ writeChapterTitleUpdates(workId, updates, rows, changeNote, timestamp) {
2232
+ const rowsById = new Map(rows.map((row) => [requiredString(row, "id"), row]));
2233
+ let updated = 0;
2234
+ for (const item of updates) {
2235
+ const row = rowsById.get(item.id);
2236
+ if (!row || requiredString(row, "title") === item.title)
2237
+ continue;
2238
+ if (item.title.length > 300) {
2239
+ throw new AppError(400, "CHAPTER_TITLE_TOO_LONG", `章节“${requiredString(row, "title").slice(0, 40)}”编号后的标题超过 300 个字符`);
2240
+ }
2241
+ const chapterId = item.id;
2242
+ const versionNo = numberValue(row, "version_no") + 1;
2243
+ this.db.run("UPDATE chapters SET title = ?, version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", item.title, versionNo, timestamp, chapterId);
2244
+ this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2245
+ this.insertChapterVersionRow({
2246
+ workId,
2247
+ chapterId,
2248
+ versionNo,
2249
+ title: item.title,
2250
+ content: requiredString(row, "content"),
2251
+ volumeId: requiredString(row, "volume_id"),
2252
+ sortOrder: numberValue(row, "sort_order"),
2253
+ chapterType: requiredString(row, "chapter_type"),
2254
+ source: "manual",
2255
+ sourceRef: null,
2256
+ changeNote,
2257
+ timestamp
2258
+ });
2259
+ this.invalidateChapter(workId, chapterId, versionNo);
2260
+ this.audit(workId, "chapter.saved", "chapter", chapterId, {
2261
+ previousTitle: requiredString(row, "title"),
2262
+ title: item.title,
2263
+ sequence: item.sequence,
2264
+ versionNo,
2265
+ renumbered: true
2266
+ });
2267
+ updated += 1;
2268
+ }
2269
+ return updated;
2270
+ }
2094
2271
  getChapter(chapterId) {
2095
2272
  const row = this.db.get(`SELECT chapter.* FROM chapters chapter
2096
2273
  JOIN volumes volume ON volume.id = chapter.volume_id
@@ -3373,6 +3550,9 @@ export class Store {
3373
3550
  offlineAccessEnabled: numberValue(row, "offline_access_enabled") === 1,
3374
3551
  editorAutoIndentEnabled: numberValue(row, "editor_auto_indent_enabled") === 1,
3375
3552
  editorTypewriterModeEnabled: numberValue(row, "editor_typewriter_mode_enabled") === 1,
3553
+ chapterTitleFormat: isChapterTitlePreference(optionalString(row, "chapter_title_format") ?? "")
3554
+ ? optionalString(row, "chapter_title_format")
3555
+ : "off",
3376
3556
  versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", workId),
3377
3557
  ownerUserId,
3378
3558
  accessRole,