@musnows/scriverse 0.7.11 → 0.7.13

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
@@ -13,6 +13,7 @@ import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, n
13
13
  import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
14
14
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
15
15
  const WORK_LIST_BATCH_SIZE = 500;
16
+ const ENTITY_LIST_BATCH_SIZE = 400;
16
17
  export const RECYCLE_BIN_RETENTION_DAYS = 30;
17
18
  function recycleBinExpiresAt(deletedAt) {
18
19
  return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
@@ -154,6 +155,29 @@ function knowledgeSectionsFromInput(sections, settingsMarkdown, settings, fallba
154
155
  function settingsFromKnowledgeSections(sections) {
155
156
  return sections.map((section) => section.contentMarkdown).filter((content) => content.trim());
156
157
  }
158
+ export const chapterOutlineBoardForeshadowSortSql = {
159
+ cte: `WITH foreshadow_associations AS MATERIALIZED (
160
+ SELECT foreshadow.work_id, occurrence.chapter_id, foreshadow.id AS foreshadow_id, foreshadow.status
161
+ FROM foreshadows foreshadow
162
+ JOIN foreshadow_occurrences occurrence ON occurrence.foreshadow_id = foreshadow.id
163
+ WHERE foreshadow.work_id = ?
164
+ UNION
165
+ SELECT foreshadow.work_id, foreshadow.planned_payoff_chapter_id AS chapter_id,
166
+ foreshadow.id AS foreshadow_id, foreshadow.status
167
+ FROM foreshadows foreshadow
168
+ WHERE foreshadow.work_id = ? AND foreshadow.planned_payoff_chapter_id IS NOT NULL
169
+ ), foreshadow_association_counts AS MATERIALIZED (
170
+ SELECT association.work_id, association.chapter_id,
171
+ SUM(CASE WHEN association.status IN ('planned', 'planted') THEN 1 ELSE 0 END) AS unresolved_count,
172
+ COUNT(*) AS total_count
173
+ FROM foreshadow_associations association
174
+ GROUP BY association.work_id, association.chapter_id
175
+ )`,
176
+ join: `LEFT JOIN foreshadow_association_counts sorted_association
177
+ ON sorted_association.work_id = chapter.work_id AND sorted_association.chapter_id = chapter.id`,
178
+ order: `COALESCE(sorted_association.unresolved_count, 0) DESC,
179
+ COALESCE(sorted_association.total_count, 0) DESC`
180
+ };
157
181
  const CHAPTER_OUTLINE_BOARD_PREVIEW_LENGTH = 600;
158
182
  function chapterOutlineBoardLikePattern(value) {
159
183
  return `%${value.normalize("NFKC").toLocaleLowerCase("zh-CN").replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
@@ -315,6 +339,18 @@ export class Store {
315
339
  const row = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
316
340
  return numberValue(row ?? {}, "version_no");
317
341
  }
342
+ currentEntityVersionNos(type, entityIds) {
343
+ const versions = new Map();
344
+ for (let offset = 0; offset < entityIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
345
+ const batchIds = entityIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
346
+ const placeholders = batchIds.map(() => "?").join(", ");
347
+ const rows = this.db.all(`SELECT entity_id, MAX(version_no) AS version_no FROM entity_versions
348
+ WHERE entity_type = ? AND entity_id IN (${placeholders}) GROUP BY entity_id`, type, ...batchIds);
349
+ for (const row of rows)
350
+ versions.set(requiredString(row, "entity_id"), numberValue(row, "version_no"));
351
+ }
352
+ return versions;
353
+ }
318
354
  currentChapterVersionNo(chapterId) {
319
355
  return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no");
320
356
  }
@@ -1240,18 +1276,19 @@ export class Store {
1240
1276
  }));
1241
1277
  return { ...work, volumes, directoryPage: pageResult };
1242
1278
  }
1243
- getStoryIndexChapterPage(workId, offset, limit) {
1279
+ getStoryIndexChapterPage(workId, offset, limit, options = {}) {
1244
1280
  const work = this.getWork(workId);
1245
1281
  const permissions = work.modulePermissions;
1246
1282
  if (permissions.prose === "none")
1247
1283
  return { totalChapters: 0, chapters: [] };
1284
+ const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
1248
1285
  const countRow = this.db.get(`SELECT COUNT(*) AS count FROM chapters chapter
1249
1286
  JOIN volumes volume ON volume.id = chapter.volume_id
1250
- WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL`, workId);
1287
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}`, workId);
1251
1288
  const chapterRows = this.db.all(`SELECT chapter.id, chapter.title, chapter.version_no, volume.title AS volume_title
1252
1289
  FROM chapters chapter
1253
1290
  JOIN volumes volume ON volume.id = chapter.volume_id
1254
- WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
1291
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}
1255
1292
  ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at
1256
1293
  LIMIT ? OFFSET ?`, workId, limit, offset);
1257
1294
  const chapterIds = chapterRows.map((row) => requiredString(row, "id"));
@@ -1492,7 +1529,12 @@ export class Store {
1492
1529
  this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
1493
1530
  WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
1494
1531
  AND (json_extract(scope_json, '$.type') = 'book'
1495
- OR (json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?))`, timestamp, workId, volumeId);
1532
+ OR (json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?)
1533
+ OR EXISTS (SELECT 1 FROM json_each(scope_json, '$.volumeIds') WHERE json_each.value = ?)
1534
+ OR EXISTS (
1535
+ SELECT 1 FROM json_each(scope_json, '$.chapterIds') selected_chapter
1536
+ WHERE selected_chapter.value IN (SELECT id FROM chapters WHERE volume_id = ?)
1537
+ ))`, timestamp, workId, volumeId, volumeId, volumeId);
1496
1538
  this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
1497
1539
  this.audit(workId, "volume.deleted", "volume", volumeId, {
1498
1540
  versionNo,
@@ -2412,12 +2454,13 @@ export class Store {
2412
2454
  this.db.run(`UPDATE chapter_paragraph_line_ranges SET chapter_version = ?
2413
2455
  WHERE paragraph_id IN (SELECT id FROM chapter_paragraph_search WHERE chapter_id = ?)`, versionNo, chapterId);
2414
2456
  }
2415
- searchChapterParagraphs(workId, keyword, limit = 20) {
2457
+ searchChapterParagraphs(workId, keyword, limit = 20, options = {}) {
2416
2458
  this.getWork(workId);
2417
2459
  const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
2418
2460
  if (!normalizedKeyword)
2419
2461
  return [];
2420
2462
  const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit)));
2463
+ const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
2421
2464
  const columns = `SELECT paragraph.chapter_id, chapter.title AS chapter_title, paragraph.content
2422
2465
  FROM chapter_paragraph_search paragraph
2423
2466
  JOIN chapters chapter ON chapter.id = paragraph.chapter_id
@@ -2425,12 +2468,12 @@ export class Store {
2425
2468
  const rows = [...normalizedKeyword].length < 3
2426
2469
  ? this.db.all(`${columns}
2427
2470
  JOIN chapter_paragraph_short_terms term ON term.paragraph_id = paragraph.id
2428
- WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND term.term = ?
2471
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND term.term = ?
2429
2472
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
2430
2473
  LIMIT ?`, workId, normalizedKeyword, safeLimit)
2431
2474
  : this.db.all(`${columns}
2432
2475
  JOIN chapter_paragraph_search_fts fts ON fts.rowid = paragraph.id
2433
- WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND chapter_paragraph_search_fts MATCH ?
2476
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND chapter_paragraph_search_fts MATCH ?
2434
2477
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
2435
2478
  LIMIT ?`, workId, `"${normalizedKeyword.replaceAll('"', '""')}"`, safeLimit);
2436
2479
  return rows.map((row) => ({
@@ -2445,9 +2488,14 @@ export class Store {
2445
2488
  AND NOT (status = 'pending' AND task_type = 'chapter-analysis'
2446
2489
  AND json_extract(scope_json, '$.chapterId') = ?)
2447
2490
  AND (json_extract(scope_json, '$.chapterId') = ?
2491
+ OR EXISTS (SELECT 1 FROM json_each(scope_json, '$.chapterIds') WHERE json_each.value = ?)
2448
2492
  OR json_extract(scope_json, '$.type') = 'book'
2449
2493
  OR (json_extract(scope_json, '$.type') = 'volume'
2450
- AND json_extract(scope_json, '$.volumeId') = (SELECT volume_id FROM chapters WHERE id = ?)))`, now(), workId, chapterId, chapterId, chapterId);
2494
+ AND (json_extract(scope_json, '$.volumeId') = (SELECT volume_id FROM chapters WHERE id = ?)
2495
+ OR EXISTS (
2496
+ SELECT 1 FROM json_each(scope_json, '$.volumeIds')
2497
+ WHERE json_each.value = (SELECT volume_id FROM chapters WHERE id = ?)
2498
+ ))))`, now(), workId, chapterId, chapterId, chapterId, chapterId, chapterId);
2451
2499
  const existing = this.db.get(`SELECT id FROM analysis_tasks WHERE work_id = ? AND task_type = 'chapter-analysis' AND status = 'pending'
2452
2500
  AND json_extract(scope_json, '$.chapterId') = ?`, workId, chapterId);
2453
2501
  if (!existing) {
@@ -2653,33 +2701,25 @@ export class Store {
2653
2701
  whereParams.push(...Array.from({ length: 8 }, () => pattern));
2654
2702
  }
2655
2703
  const whereSql = where.join(" AND ");
2656
- const associationCount = (statusSql = "") => `(
2657
- SELECT COUNT(*) FROM foreshadows sorted_foreshadow
2658
- WHERE sorted_foreshadow.work_id = chapter.work_id${statusSql}
2659
- AND (
2660
- sorted_foreshadow.planned_payoff_chapter_id = chapter.id
2661
- OR EXISTS (
2662
- SELECT 1 FROM foreshadow_occurrences sorted_occurrence
2663
- WHERE sorted_occurrence.foreshadow_id = sorted_foreshadow.id
2664
- AND sorted_occurrence.chapter_id = chapter.id
2665
- )
2666
- )
2667
- )`;
2668
2704
  const chapterTreeOrder = "chapter.sort_order, chapter.created_at, chapter.id";
2669
2705
  const chapterOrder = filters.sort === "status"
2670
2706
  ? `CASE WHEN outline.chapter_id IS NULL THEN 0 WHEN outline.status = 'draft' THEN 1 WHEN outline.status = 'ready' THEN 2 ELSE 3 END, ${chapterTreeOrder}`
2671
2707
  : filters.sort === "foreshadows"
2672
- ? `${associationCount(" AND sorted_foreshadow.status IN ('planned', 'planted')")} DESC, ${associationCount()} DESC, ${chapterTreeOrder}`
2708
+ ? `${chapterOutlineBoardForeshadowSortSql.order}, ${chapterTreeOrder}`
2673
2709
  : filters.sort === "title"
2674
2710
  ? `chapter.title COLLATE NOCASE, ${chapterTreeOrder}`
2675
2711
  : chapterTreeOrder;
2676
2712
  const orderSql = `volume.sort_order, volume.created_at, volume.id, ${chapterOrder}`;
2713
+ const foreshadowSortCte = filters.sort === "foreshadows" ? chapterOutlineBoardForeshadowSortSql.cte : "";
2714
+ const foreshadowSortJoin = filters.sort === "foreshadows" ? chapterOutlineBoardForeshadowSortSql.join : "";
2715
+ const foreshadowSortParams = filters.sort === "foreshadows" ? [workId, workId] : [];
2677
2716
  const total = numberValue(this.db.get(`SELECT COUNT(*) AS count
2678
2717
  FROM chapters chapter
2679
2718
  JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
2680
2719
  LEFT JOIN chapter_outlines outline ON outline.chapter_id = chapter.id
2681
2720
  WHERE ${whereSql}`, ...whereParams) ?? {}, "count");
2682
- const pageRows = this.db.all(`SELECT volume.id AS volume_id, volume.title AS volume_title, volume.sort_order AS volume_order,
2721
+ const pageRows = this.db.all(`${foreshadowSortCte}
2722
+ SELECT volume.id AS volume_id, volume.title AS volume_title, volume.sort_order AS volume_order,
2683
2723
  chapter.id AS chapter_id, chapter.title AS chapter_title, chapter.chapter_type,
2684
2724
  chapter.sort_order AS chapter_order,
2685
2725
  outline.chapter_id AS outline_chapter_id,
@@ -2691,9 +2731,10 @@ export class Store {
2691
2731
  FROM chapters chapter
2692
2732
  JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
2693
2733
  LEFT JOIN chapter_outlines outline ON outline.chapter_id = chapter.id
2734
+ ${foreshadowSortJoin}
2694
2735
  WHERE ${whereSql}
2695
2736
  ORDER BY ${orderSql}
2696
- LIMIT ? OFFSET ?`, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, ...whereParams, pagination.limit + 1, pagination.offset);
2737
+ LIMIT ? OFFSET ?`, ...foreshadowSortParams, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, ...whereParams, pagination.limit + 1, pagination.offset);
2697
2738
  const chapterPage = paginated(pageRows, pagination, total);
2698
2739
  const volumeRows = this.db.all(`SELECT volume.id, volume.title, volume.sort_order,
2699
2740
  COUNT(chapter.id) AS chapter_count
@@ -3003,20 +3044,25 @@ export class Store {
3003
3044
  WHERE fo.foreshadow_id = ? ORDER BY v.sort_order, c.sort_order, fo.created_at`, foreshadowId).map((item) => this.mapForeshadowOccurrence(item));
3004
3045
  const status = requiredString(row, "status");
3005
3046
  const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
3047
+ const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
3048
+ && this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId));
3049
+ return this.mapForeshadow(row, occurrences, this.currentEntityVersionNo("foreshadow", foreshadowId), overdue);
3050
+ }
3051
+ mapForeshadow(row, occurrences, versionNo, overdue) {
3052
+ const status = requiredString(row, "status");
3006
3053
  return {
3007
3054
  id: requiredString(row, "id"),
3008
- workId,
3055
+ workId: requiredString(row, "work_id"),
3009
3056
  title: requiredString(row, "title"),
3010
3057
  description: requiredString(row, "description"),
3011
3058
  status,
3012
3059
  importance: requiredString(row, "importance"),
3013
- plannedPayoffChapterId,
3060
+ plannedPayoffChapterId: optionalString(row, "planned_payoff_chapter_id"),
3014
3061
  resolutionNote: requiredString(row, "resolution_note"),
3015
3062
  unresolved: status === "planned" || status === "planted",
3016
- overdue: Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
3017
- && this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId)),
3063
+ overdue,
3018
3064
  occurrences,
3019
- versionNo: this.currentEntityVersionNo("foreshadow", foreshadowId),
3065
+ versionNo,
3020
3066
  createdAt: requiredString(row, "created_at"),
3021
3067
  updatedAt: requiredString(row, "updated_at")
3022
3068
  };
@@ -3028,8 +3074,9 @@ export class Store {
3028
3074
  const where = status === "unresolved"
3029
3075
  ? "AND status IN ('planned', 'planted')"
3030
3076
  : status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
3031
- return this.db.all(`SELECT id FROM foreshadows WHERE work_id = ? ${where}
3032
- ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId).map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId));
3077
+ const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
3078
+ ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId);
3079
+ return this.mapForeshadowList(rows, currentChapterId);
3033
3080
  }
3034
3081
  listForeshadowsPage(workId, pagination, status = "all", currentChapterId) {
3035
3082
  this.getWork(workId);
@@ -3039,9 +3086,63 @@ export class Store {
3039
3086
  ? "AND status IN ('planned', 'planted')"
3040
3087
  : status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
3041
3088
  const page = paginationSql(pagination);
3042
- const rows = this.db.all(`SELECT id FROM foreshadows WHERE work_id = ? ${where}
3089
+ const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
3043
3090
  ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
3044
- return paginated(rows.map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId)), pagination);
3091
+ return paginated(this.mapForeshadowList(rows, currentChapterId), pagination);
3092
+ }
3093
+ mapForeshadowList(rows, currentChapterId) {
3094
+ if (rows.length === 0)
3095
+ return [];
3096
+ const foreshadowIds = rows.map((row) => requiredString(row, "id"));
3097
+ const batch = {
3098
+ occurrences: new Map(),
3099
+ versions: this.currentEntityVersionNos("foreshadow", foreshadowIds),
3100
+ chapterSequences: new Map()
3101
+ };
3102
+ for (let offset = 0; offset < foreshadowIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
3103
+ const batchIds = foreshadowIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
3104
+ const placeholders = batchIds.map(() => "?").join(", ");
3105
+ const occurrences = this.db.all(`SELECT fo.*, c.title AS chapter_title, c.volume_id, c.sort_order AS chapter_order,
3106
+ v.title AS volume_title, v.sort_order AS volume_order
3107
+ FROM foreshadow_occurrences fo
3108
+ JOIN chapters c ON c.id = fo.chapter_id
3109
+ JOIN volumes v ON v.id = c.volume_id
3110
+ WHERE fo.foreshadow_id IN (${placeholders})
3111
+ ORDER BY fo.foreshadow_id, v.sort_order, c.sort_order, fo.created_at`, ...batchIds);
3112
+ for (const occurrence of occurrences) {
3113
+ const foreshadowId = requiredString(occurrence, "foreshadow_id");
3114
+ const grouped = batch.occurrences.get(foreshadowId) ?? [];
3115
+ grouped.push(this.mapForeshadowOccurrence(occurrence));
3116
+ batch.occurrences.set(foreshadowId, grouped);
3117
+ }
3118
+ }
3119
+ if (currentChapterId) {
3120
+ const chapterIds = [...new Set([
3121
+ currentChapterId,
3122
+ ...rows.map((row) => optionalString(row, "planned_payoff_chapter_id")).filter((chapterId) => Boolean(chapterId))
3123
+ ])];
3124
+ for (let offset = 0; offset < chapterIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
3125
+ const batchIds = chapterIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
3126
+ const placeholders = batchIds.map(() => "?").join(", ");
3127
+ const sequences = this.db.all(`SELECT c.id, v.sort_order * 1000000 + c.sort_order AS sequence
3128
+ FROM chapters c JOIN volumes v ON v.id = c.volume_id
3129
+ WHERE c.id IN (${placeholders}) AND c.work_id = ?`, ...batchIds, requiredString(rows[0] ?? {}, "work_id"));
3130
+ for (const sequence of sequences) {
3131
+ batch.chapterSequences.set(requiredString(sequence, "id"), numberValue(sequence, "sequence"));
3132
+ }
3133
+ }
3134
+ }
3135
+ const currentChapterSequence = currentChapterId
3136
+ ? batch.chapterSequences.get(currentChapterId) ?? Number.MAX_SAFE_INTEGER
3137
+ : Number.MAX_SAFE_INTEGER;
3138
+ return rows.map((row) => {
3139
+ const foreshadowId = requiredString(row, "id");
3140
+ const status = requiredString(row, "status");
3141
+ const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
3142
+ const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
3143
+ && (batch.chapterSequences.get(plannedPayoffChapterId) ?? Number.MAX_SAFE_INTEGER) < currentChapterSequence);
3144
+ return this.mapForeshadow(row, batch.occurrences.get(foreshadowId) ?? [], batch.versions.get(foreshadowId) ?? 0, overdue);
3145
+ });
3045
3146
  }
3046
3147
  listChapterForeshadowReminders(workId, chapterId) {
3047
3148
  this.getWork(workId);
@@ -3724,13 +3825,16 @@ export class Store {
3724
3825
  }
3725
3826
  listOrganizations(workId, includeMarkdown = true) {
3726
3827
  this.getWork(workId);
3727
- return this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId).map((row) => this.mapOrganization(row, includeMarkdown));
3828
+ const rows = this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId);
3829
+ const batch = this.organizationListBatch(rows);
3830
+ return rows.map((row) => this.mapOrganization(row, includeMarkdown, batch));
3728
3831
  }
3729
3832
  listOrganizationsPage(workId, pagination, includeMarkdown = true) {
3730
3833
  this.getWork(workId);
3731
3834
  const page = paginationSql(pagination);
3732
3835
  const rows = this.db.all(`SELECT * FROM organizations WHERE work_id = ? ORDER BY name${page.sql}`, workId, ...page.params);
3733
- return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown)), pagination);
3836
+ const batch = this.organizationListBatch(rows);
3837
+ return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown, batch)), pagination);
3734
3838
  }
3735
3839
  getOrganization(organizationId) {
3736
3840
  const row = this.db.get("SELECT * FROM organizations WHERE id = ?", organizationId);
@@ -3817,20 +3921,50 @@ export class Store {
3817
3921
  });
3818
3922
  return { mergeId, target: this.getOrganization(targetOrganizationId), source };
3819
3923
  }
3820
- mapOrganization(row, includeMarkdown = true) {
3924
+ organizationListBatch(rows) {
3925
+ const organizationIds = rows.map((row) => requiredString(row, "id"));
3926
+ const batch = {
3927
+ members: new Map(),
3928
+ versions: this.currentEntityVersionNos("organization", organizationIds)
3929
+ };
3930
+ for (let offset = 0; offset < organizationIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
3931
+ const batchIds = organizationIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
3932
+ const placeholders = batchIds.map(() => "?").join(", ");
3933
+ const members = this.db.all(`SELECT m.organization_id, c.id, c.name, m.role, m.note
3934
+ FROM character_organization_memberships m
3935
+ JOIN characters c ON c.id = m.character_id
3936
+ WHERE m.organization_id IN (${placeholders}) ORDER BY m.organization_id, c.name`, ...batchIds);
3937
+ for (const member of members) {
3938
+ const organizationId = requiredString(member, "organization_id");
3939
+ const grouped = batch.members.get(organizationId) ?? [];
3940
+ grouped.push({
3941
+ characterId: requiredString(member, "id"),
3942
+ name: requiredString(member, "name"),
3943
+ role: requiredString(member, "role"),
3944
+ note: requiredString(member, "note")
3945
+ });
3946
+ batch.members.set(organizationId, grouped);
3947
+ }
3948
+ }
3949
+ return batch;
3950
+ }
3951
+ mapOrganization(row, includeMarkdown = true, batch) {
3952
+ const organizationId = requiredString(row, "id");
3821
3953
  const settingsSections = knowledgeSectionsFromStored(row.settings_sections_json, json(requiredString(row, "settings_json"), []));
3822
3954
  const settings = settingsFromKnowledgeSections(settingsSections);
3823
- const members = this.db.all(`SELECT c.id, c.name, m.role, m.note
3824
- FROM character_organization_memberships m
3825
- JOIN characters c ON c.id = m.character_id
3826
- WHERE m.organization_id = ? ORDER BY c.name`, requiredString(row, "id")).map((member) => ({
3827
- characterId: requiredString(member, "id"),
3828
- name: requiredString(member, "name"),
3829
- role: requiredString(member, "role"),
3830
- note: requiredString(member, "note")
3831
- }));
3955
+ const members = batch
3956
+ ? batch.members.get(organizationId) ?? []
3957
+ : this.db.all(`SELECT c.id, c.name, m.role, m.note
3958
+ FROM character_organization_memberships m
3959
+ JOIN characters c ON c.id = m.character_id
3960
+ WHERE m.organization_id = ? ORDER BY c.name`, organizationId).map((member) => ({
3961
+ characterId: requiredString(member, "id"),
3962
+ name: requiredString(member, "name"),
3963
+ role: requiredString(member, "role"),
3964
+ note: requiredString(member, "note")
3965
+ }));
3832
3966
  return {
3833
- id: requiredString(row, "id"),
3967
+ id: organizationId,
3834
3968
  workId: requiredString(row, "work_id"),
3835
3969
  name: requiredString(row, "name"),
3836
3970
  description: requiredString(row, "description"),
@@ -3840,7 +3974,7 @@ export class Store {
3840
3974
  : { settings: [], settingsCount: settingsSections.length }),
3841
3975
  memberIds: members.map((member) => member.characterId),
3842
3976
  members,
3843
- versionNo: this.currentEntityVersionNo("organization", requiredString(row, "id")),
3977
+ versionNo: batch ? batch.versions.get(organizationId) ?? 0 : this.currentEntityVersionNo("organization", organizationId),
3844
3978
  createdAt: requiredString(row, "created_at"),
3845
3979
  updatedAt: requiredString(row, "updated_at")
3846
3980
  };
@@ -5626,6 +5760,43 @@ export class Store {
5626
5760
  throw notFound("AI 对话消息");
5627
5761
  return this.mapAiConversationMessage(persistInterruption(message));
5628
5762
  }
5763
+ upsertAiConversationAssistantMessage(conversationId, requestId, content, metadata = {}, syncSearchIndex = false) {
5764
+ const normalizedRequestId = requestId.trim();
5765
+ if (!normalizedRequestId)
5766
+ throw new AppError(400, "AI_MESSAGE_REQUEST_ID_REQUIRED", "AI 助手消息缺少请求标识");
5767
+ const existing = this.db.get("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? AND request_id = ?", conversationId, normalizedRequestId);
5768
+ if (!existing) {
5769
+ return this.addAiConversationMessage(conversationId, {
5770
+ role: "assistant",
5771
+ content,
5772
+ requestId: normalizedRequestId,
5773
+ metadata
5774
+ });
5775
+ }
5776
+ if (requiredString(existing, "role") !== "assistant") {
5777
+ throw new AppError(409, "AI_MESSAGE_ROLE_MISMATCH", "请求标识已用于用户消息");
5778
+ }
5779
+ const currentMetadata = json(requiredString(existing, "metadata_json"), {});
5780
+ const nextMetadata = { ...currentMetadata, ...metadata };
5781
+ const contentChanged = requiredString(existing, "content") !== content;
5782
+ const metadataChanged = JSON.stringify(currentMetadata) !== JSON.stringify(nextMetadata);
5783
+ if (!contentChanged && !metadataChanged) {
5784
+ if (syncSearchIndex)
5785
+ this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
5786
+ return this.mapAiConversationMessage(existing);
5787
+ }
5788
+ const timestamp = now();
5789
+ this.db.transaction(() => {
5790
+ this.db.run("UPDATE ai_conversation_messages SET content = ?, metadata_json = ? WHERE id = ?", content, JSON.stringify(nextMetadata), requiredString(existing, "id"));
5791
+ this.db.run("UPDATE ai_conversations SET updated_at = ? WHERE id = ?", timestamp, conversationId);
5792
+ if (syncSearchIndex)
5793
+ this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
5794
+ });
5795
+ const updated = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", requiredString(existing, "id"));
5796
+ if (!updated)
5797
+ throw notFound("AI 对话消息");
5798
+ return this.mapAiConversationMessage(updated);
5799
+ }
5629
5800
  beginAiConversationStreamRequest(input, referenceTime = new Date()) {
5630
5801
  const timestamp = referenceTime.toISOString();
5631
5802
  const leaseExpiresAt = new Date(referenceTime.getTime() + AI_CONVERSATION_STREAM_REQUEST_LEASE_MS).toISOString();
@@ -5743,10 +5914,18 @@ export class Store {
5743
5914
  if (!assistant)
5744
5915
  throw new AppError(400, "AI_STREAM_ASSISTANT_MISMATCH", "AI 回复消息不属于当前对话请求");
5745
5916
  }
5917
+ const resolvedAssistantMessageId = assistantMessageId ?? (() => {
5918
+ const userMessageId = optionalString(request, "user_message_id");
5919
+ if (!userMessageId)
5920
+ return null;
5921
+ const assistant = this.db.get(`SELECT id FROM ai_conversation_messages
5922
+ WHERE conversation_id = ? AND role = 'assistant' AND request_id = ?`, requiredString(request, "conversation_id"), `assistant:${userMessageId}`);
5923
+ return assistant ? requiredString(assistant, "id") : null;
5924
+ })();
5746
5925
  this.db.run(`UPDATE ai_conversation_stream_requests
5747
5926
  SET status = ?, terminal_reason = ?, assistant_message_id = COALESCE(assistant_message_id, ?),
5748
5927
  lease_expires_at = NULL, updated_at = ?, completed_at = ?
5749
- WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500), assistantMessageId ?? null, timestamp, timestamp, requestId);
5928
+ WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500), resolvedAssistantMessageId, timestamp, timestamp, requestId);
5750
5929
  const completed = this.db.get("SELECT * FROM ai_conversation_stream_requests WHERE id = ?", requestId);
5751
5930
  if (!completed)
5752
5931
  throw notFound("AI 对话请求");
@@ -5936,65 +6115,101 @@ export class Store {
5936
6115
  }
5937
6116
  relationshipRosterSourceVersions(workId) {
5938
6117
  const versions = {};
5939
- for (const character of this.listCharacters(workId, false)) {
5940
- versions[`character:${String(character.id)}`] = Number(character.versionNo);
5941
- }
5942
- for (const race of this.listRaces(workId))
5943
- versions[`race:${String(race.id)}`] = Number(race.versionNo);
5944
- for (const organization of this.listOrganizations(workId)) {
5945
- versions[`organization:${String(organization.id)}`] = Number(organization.versionNo);
5946
- }
5947
- for (const relationship of this.listRelationships(workId)) {
5948
- versions[`relationship:${String(relationship.id)}`] = Number(relationship.versionNo);
6118
+ for (const row of this.db.all("SELECT id, version_no FROM characters WHERE work_id = ? AND merged_into_character_id IS NULL", workId)) {
6119
+ versions[`character:${requiredString(row, "id")}`] = numberValue(row, "version_no");
5949
6120
  }
6121
+ this.appendVersionedEntitySourceVersions(versions, workId, "race", "races");
6122
+ this.appendVersionedEntitySourceVersions(versions, workId, "organization", "organizations");
6123
+ this.appendVersionedEntitySourceVersions(versions, workId, "relationship", "relationships");
5950
6124
  return versions;
5951
6125
  }
6126
+ appendVersionedEntitySourceVersions(versions, workId, entityType, table) {
6127
+ const rows = this.db.all(`SELECT entity.id, COALESCE(MAX(version.version_no), 0) AS version_no
6128
+ FROM ${table} entity
6129
+ LEFT JOIN entity_versions version
6130
+ ON version.work_id = entity.work_id AND version.entity_type = ? AND version.entity_id = entity.id
6131
+ WHERE entity.work_id = ?
6132
+ GROUP BY entity.id`, entityType, workId);
6133
+ for (const row of rows) {
6134
+ versions[`${entityType}:${requiredString(row, "id")}`] = numberValue(row, "version_no");
6135
+ }
6136
+ }
5952
6137
  relationshipSettingsSourceVersions(workId) {
5953
6138
  const versions = this.relationshipRosterSourceVersions(workId);
5954
- const work = this.getWork(workId);
5955
- versions[`work:${workId}`] = Number(work.versionNo);
5956
- for (const setting of this.listSettings(workId))
5957
- versions[`setting:${String(setting.id)}`] = Number(setting.versionNo);
5958
- for (const character of this.listCharacters(workId, true)) {
5959
- for (const section of this.listCharacterProfileSections(String(character.id))) {
5960
- versions[`character-section:${String(section.id)}`] = Number(section.versionNo);
5961
- }
5962
- }
5963
- for (const track of this.listTimelineTracks(workId))
5964
- versions[`timeline-track:${String(track.id)}`] = Number(track.versionNo);
5965
- for (const event of this.listTimelineEvents(workId))
5966
- versions[`timeline-event:${String(event.id)}`] = Number(event.versionNo);
5967
- for (const outline of this.listChapterOutlines(workId)) {
5968
- const chapterId = String(outline.chapterId);
5969
- versions[`chapter-meta:${chapterId}`] = Number(this.getChapter(chapterId).versionNo);
5970
- versions[`chapter-outline:${chapterId}`] = this.currentEntityVersionNo("chapter-outline", chapterId);
5971
- }
5972
- for (const foreshadow of this.listForeshadows(workId)) {
5973
- versions[`foreshadow:${String(foreshadow.id)}`] = Number(foreshadow.versionNo);
6139
+ const work = this.db.get("SELECT version_no FROM works WHERE id = ? AND deleted_at IS NULL", workId);
6140
+ if (!work)
6141
+ throw notFound("作品");
6142
+ versions[`work:${workId}`] = numberValue(work, "version_no");
6143
+ this.appendVersionedEntitySourceVersions(versions, workId, "setting", "settings");
6144
+ for (const row of this.db.all(`SELECT section.id, section.version_no
6145
+ FROM character_profile_sections section
6146
+ JOIN characters character ON character.id = section.character_id
6147
+ WHERE section.work_id = ? AND character.merged_into_character_id IS NULL`, workId)) {
6148
+ versions[`character-section:${requiredString(row, "id")}`] = numberValue(row, "version_no");
6149
+ }
6150
+ this.appendVersionedEntitySourceVersions(versions, workId, "timeline-track", "timeline_tracks");
6151
+ this.appendVersionedEntitySourceVersions(versions, workId, "timeline-event", "timeline_events");
6152
+ for (const row of this.db.all(`SELECT chapter.id, chapter.version_no AS chapter_version_no,
6153
+ COALESCE(MAX(version.version_no), 0) AS outline_version_no
6154
+ FROM chapter_outlines outline
6155
+ JOIN chapters chapter ON chapter.id = outline.chapter_id
6156
+ LEFT JOIN entity_versions version
6157
+ ON version.work_id = chapter.work_id
6158
+ AND version.entity_type = 'chapter-outline'
6159
+ AND version.entity_id = chapter.id
6160
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL
6161
+ GROUP BY chapter.id, chapter.version_no`, workId)) {
6162
+ const chapterId = requiredString(row, "id");
6163
+ versions[`chapter-meta:${chapterId}`] = numberValue(row, "chapter_version_no");
6164
+ versions[`chapter-outline:${chapterId}`] = numberValue(row, "outline_version_no");
5974
6165
  }
5975
- for (const review of this.listReviewItems(workId)) {
5976
- versions[`review:${String(review.id)}`] = String(review.updatedAt);
6166
+ this.appendVersionedEntitySourceVersions(versions, workId, "foreshadow", "foreshadows");
6167
+ for (const row of this.db.all("SELECT id, updated_at FROM review_items WHERE work_id = ?", workId)) {
6168
+ versions[`review:${requiredString(row, "id")}`] = requiredString(row, "updated_at");
5977
6169
  }
5978
6170
  return versions;
5979
6171
  }
5980
6172
  analysisTaskSourceVersions(workId, scope) {
5981
6173
  const sourceVersions = {};
5982
- if (typeof scope.chapterId === "string") {
5983
- const chapter = this.getChapter(scope.chapterId);
5984
- if (chapter.workId !== workId)
6174
+ const selectedChapterIds = [
6175
+ ...(typeof scope.chapterId === "string" ? [scope.chapterId] : []),
6176
+ ...(Array.isArray(scope.chapterIds) ? scope.chapterIds.filter((value) => typeof value === "string") : [])
6177
+ ];
6178
+ for (const chapterId of [...new Set(selectedChapterIds)]) {
6179
+ const chapter = this.db.get("SELECT work_id, version_no, chapter_type FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
6180
+ if (!chapter)
6181
+ throw notFound("章节");
6182
+ if (requiredString(chapter, "work_id") !== workId)
5985
6183
  throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
5986
- sourceVersions[scope.chapterId] = Number(chapter.versionNo);
5987
- }
5988
- else if (scope.type === "book" || scope.type === "volume") {
5989
- const tree = this.getWorkTree(workId);
5990
- const volumes = tree.volumes;
5991
- const selectedVolumes = scope.type === "volume"
5992
- ? volumes.filter((volume) => volume.id === scope.volumeId)
5993
- : volumes;
5994
- if (scope.type === "volume" && selectedVolumes.length === 0)
5995
- throw notFound("卷");
5996
- for (const chapter of selectedVolumes.flatMap((volume) => volume.chapters)) {
5997
- sourceVersions[String(chapter.id)] = Number(chapter.versionNo);
6184
+ if (requiredString(chapter, "chapter_type") === "作者的话")
6185
+ continue;
6186
+ sourceVersions[chapterId] = numberValue(chapter, "version_no");
6187
+ }
6188
+ if (scope.type === "book" || scope.type === "volume") {
6189
+ const selectedVolumeIds = [
6190
+ ...(typeof scope.volumeId === "string" ? [scope.volumeId] : []),
6191
+ ...(Array.isArray(scope.volumeIds) ? scope.volumeIds.filter((value) => typeof value === "string") : [])
6192
+ ];
6193
+ const selectedVolumeIdSet = new Set(selectedVolumeIds);
6194
+ if (scope.type === "volume") {
6195
+ if (selectedVolumeIdSet.size === 0)
6196
+ throw notFound("卷");
6197
+ const volumePlaceholders = [...selectedVolumeIdSet].map(() => "?").join(", ");
6198
+ const volumes = this.db.all(`SELECT id FROM volumes WHERE work_id = ? AND deleted_at IS NULL AND id IN (${volumePlaceholders})`, workId, ...selectedVolumeIdSet);
6199
+ if (volumes.length !== selectedVolumeIdSet.size)
6200
+ throw notFound("卷");
6201
+ }
6202
+ const volumeFilter = scope.type === "volume"
6203
+ ? `AND chapter.volume_id IN (${[...selectedVolumeIdSet].map(() => "?").join(", ")})`
6204
+ : "";
6205
+ const chapterRows = this.db.all(`SELECT chapter.id, chapter.version_no
6206
+ FROM chapters chapter
6207
+ JOIN volumes volume ON volume.id = chapter.volume_id
6208
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
6209
+ AND chapter.chapter_type <> '作者的话'
6210
+ ${volumeFilter}`, workId, ...(scope.type === "volume" ? [...selectedVolumeIdSet] : []));
6211
+ for (const chapter of chapterRows) {
6212
+ sourceVersions[requiredString(chapter, "id")] = numberValue(chapter, "version_no");
5998
6213
  }
5999
6214
  }
6000
6215
  if (scope.previewRelationshipChanges === true) {
@@ -6029,10 +6244,12 @@ export class Store {
6029
6244
  for (const characterId of scope.characterIds) {
6030
6245
  if (typeof characterId !== "string")
6031
6246
  throw new AppError(400, "CHARACTER_REQUIRED", "被分析角色标识无效");
6032
- const character = this.getCharacter(characterId);
6033
- if (character.workId !== workId)
6247
+ const character = this.db.get("SELECT work_id, name FROM characters WHERE id = ?", characterId);
6248
+ if (!character)
6249
+ throw notFound("角色");
6250
+ if (requiredString(character, "work_id") !== workId)
6034
6251
  throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
6035
- targetCharacters.push({ id: characterId, name: String(character.name) });
6252
+ targetCharacters.push({ id: characterId, name: requiredString(character, "name") });
6036
6253
  }
6037
6254
  if (targetCharacters.length > 0)
6038
6255
  scope.targetCharacters = targetCharacters;
@@ -7158,8 +7375,22 @@ export class Store {
7158
7375
  }
7159
7376
  taskScopeSummaryFromMaps(scope, chapterSummaries, volumeTitles, characterNames, includeCharacterNames = true) {
7160
7377
  const targetedSuffix = this.taskTargetedSuffix(scope, characterNames, includeCharacterNames);
7378
+ if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
7379
+ const labels = scope.chapterIds
7380
+ .filter((chapterId) => typeof chapterId === "string")
7381
+ .map((chapterId) => chapterSummaries.get(chapterId) ?? "章节已删除");
7382
+ const preview = labels.slice(0, 3).join("、");
7383
+ return `指定章节(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
7384
+ }
7161
7385
  if (typeof scope.chapterId === "string")
7162
7386
  return `${chapterSummaries.get(scope.chapterId) ?? "章节已删除"}${targetedSuffix}`;
7387
+ if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
7388
+ const labels = scope.volumeIds
7389
+ .filter((volumeId) => typeof volumeId === "string")
7390
+ .map((volumeId) => volumeTitles.get(volumeId) ? `分卷 · ${volumeTitles.get(volumeId)}` : "分卷已删除");
7391
+ const preview = labels.slice(0, 3).join("、");
7392
+ return `指定分卷(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
7393
+ }
7163
7394
  if (scope.type === "volume" && typeof scope.volumeId === "string") {
7164
7395
  const title = volumeTitles.get(scope.volumeId);
7165
7396
  return `${title ? `分卷 · ${title}` : "分卷已删除"}${targetedSuffix}`;
@@ -7178,6 +7409,17 @@ export class Store {
7178
7409
  }
7179
7410
  taskScopeSummary(workId, scope, characterNames, includeCharacterNames = true) {
7180
7411
  const targetedSuffix = this.taskTargetedSuffix(scope, characterNames, includeCharacterNames);
7412
+ if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
7413
+ const labels = scope.chapterIds.map((chapterId) => {
7414
+ const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
7415
+ FROM chapters chapter
7416
+ JOIN volumes volume ON volume.id = chapter.volume_id
7417
+ WHERE chapter.id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, chapterId, workId);
7418
+ return chapter ? `${requiredString(chapter, "volume_title")} · ${requiredString(chapter, "title")}` : "章节已删除";
7419
+ });
7420
+ const preview = labels.slice(0, 3).join("、");
7421
+ return `指定章节(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
7422
+ }
7181
7423
  if (typeof scope.chapterId === "string") {
7182
7424
  const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
7183
7425
  FROM chapters chapter
@@ -7189,6 +7431,14 @@ export class Store {
7189
7431
  const volumeTitle = requiredString(chapter, "volume_title");
7190
7432
  return `${volumeTitle} · ${title}${targetedSuffix}`;
7191
7433
  }
7434
+ if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
7435
+ const labels = scope.volumeIds.map((volumeId) => {
7436
+ const volume = this.db.get("SELECT title FROM volumes WHERE id = ? AND work_id = ?", volumeId, workId);
7437
+ return volume ? `分卷 · ${requiredString(volume, "title")}` : "分卷已删除";
7438
+ });
7439
+ const preview = labels.slice(0, 3).join("、");
7440
+ return `指定分卷(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
7441
+ }
7192
7442
  if (scope.type === "volume" && typeof scope.volumeId === "string") {
7193
7443
  const volume = this.db.get("SELECT title FROM volumes WHERE id = ? AND work_id = ?", scope.volumeId, workId);
7194
7444
  return `${volume ? `分卷 · ${requiredString(volume, "title")}` : "分卷已删除"}${targetedSuffix}`;
@@ -7296,6 +7546,25 @@ export class Store {
7296
7546
  return snapshotNames;
7297
7547
  }
7298
7548
  taskScopeDetails(workId, scope) {
7549
+ if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
7550
+ return scope.chapterIds.map((chapterId) => {
7551
+ const chapter = this.db.get(`SELECT chapter.id AS id, chapter.title AS title, chapter.version_no AS version_no,
7552
+ volume.id AS volume_id, volume.title AS volume_title
7553
+ FROM chapters chapter
7554
+ JOIN volumes volume ON volume.id = chapter.volume_id
7555
+ WHERE chapter.id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, chapterId, workId);
7556
+ if (!chapter)
7557
+ return { type: "chapter", chapterId, missing: true };
7558
+ return {
7559
+ type: "chapter",
7560
+ chapterId: requiredString(chapter, "id"),
7561
+ title: requiredString(chapter, "title"),
7562
+ versionNo: numberValue(chapter, "version_no"),
7563
+ volumeId: requiredString(chapter, "volume_id"),
7564
+ volumeTitle: requiredString(chapter, "volume_title")
7565
+ };
7566
+ });
7567
+ }
7299
7568
  if (typeof scope.chapterId === "string") {
7300
7569
  const chapter = this.db.get(`SELECT chapter.id AS id, chapter.title AS title, chapter.version_no AS version_no,
7301
7570
  volume.id AS volume_id, volume.title AS volume_title
@@ -7313,6 +7582,24 @@ export class Store {
7313
7582
  volumeTitle: requiredString(chapter, "volume_title")
7314
7583
  }];
7315
7584
  }
7585
+ if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
7586
+ return scope.volumeIds.map((volumeId) => {
7587
+ const volume = this.db.get("SELECT id, title FROM volumes WHERE id = ? AND work_id = ?", volumeId, workId);
7588
+ if (!volume)
7589
+ return { type: "volume", volumeId, missing: true };
7590
+ const chapters = this.db.all("SELECT id, title, version_no FROM chapters WHERE volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", volumeId);
7591
+ return {
7592
+ type: "volume",
7593
+ volumeId: requiredString(volume, "id"),
7594
+ title: requiredString(volume, "title"),
7595
+ chapters: chapters.map((item) => ({
7596
+ chapterId: requiredString(item, "id"),
7597
+ title: requiredString(item, "title"),
7598
+ versionNo: numberValue(item, "version_no")
7599
+ }))
7600
+ };
7601
+ });
7602
+ }
7316
7603
  if (scope.type === "volume" && typeof scope.volumeId === "string") {
7317
7604
  const volume = this.db.get("SELECT id, title FROM volumes WHERE id = ? AND work_id = ?", scope.volumeId, workId);
7318
7605
  if (!volume)