@musnows/scriverse 0.7.5 → 0.7.7

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.
@@ -6,90 +6,18 @@ function text(value) {
6
6
  return String(value ?? "");
7
7
  }
8
8
 
9
- function normalizedSearchText(value) {
10
- return text(value).normalize("NFKC").trim().toLocaleLowerCase("zh-CN");
11
- }
12
-
13
- function numericOrder(value) {
14
- const candidate = Number(value);
15
- return Number.isFinite(candidate) ? candidate : 0;
16
- }
17
-
18
- function stableIdCompare(left, right) {
19
- return text(left?.id).localeCompare(text(right?.id), "zh-CN");
20
- }
21
-
22
- function treeCompare(left, right) {
23
- const delta = numericOrder(left?.sortOrder) - numericOrder(right?.sortOrder);
24
- return delta || stableIdCompare(left, right);
25
- }
26
-
27
- function outlineStatusRank(chapter) {
28
- if (!chapter?.outline) return 0;
29
- return { draft: 1, ready: 2, completed: 3 }[chapter.outline.status] ?? 1;
30
- }
31
-
32
9
  function unresolvedForeshadowCount(chapter) {
33
10
  return (Array.isArray(chapter?.foreshadows) ? chapter.foreshadows : [])
34
11
  .filter((foreshadow) => foreshadow?.status === "planned" || foreshadow?.status === "planted")
35
12
  .length;
36
13
  }
37
14
 
38
- function compareChapters(left, right, sort) {
39
- if (sort === "status") {
40
- const delta = outlineStatusRank(left) - outlineStatusRank(right);
41
- if (delta) return delta;
42
- }
43
- if (sort === "foreshadows") {
44
- const unresolvedDelta = unresolvedForeshadowCount(right) - unresolvedForeshadowCount(left);
45
- if (unresolvedDelta) return unresolvedDelta;
46
- const totalDelta = (right?.foreshadows?.length ?? 0) - (left?.foreshadows?.length ?? 0);
47
- if (totalDelta) return totalDelta;
48
- }
49
- if (sort === "title") {
50
- const delta = text(left?.title).localeCompare(text(right?.title), "zh-CN");
51
- if (delta) return delta;
52
- }
53
- return treeCompare(left, right);
54
- }
55
-
56
- function matchesQuery(chapter, query) {
57
- if (!query) return true;
58
- const outline = chapter?.outline ?? {};
59
- const values = [
60
- chapter?.title,
61
- chapter?.chapterType,
62
- outline.goal,
63
- outline.conflict,
64
- outline.turningPoint,
65
- outline.notes,
66
- ...(Array.isArray(chapter?.foreshadows) ? chapter.foreshadows.map((item) => item?.title) : [])
67
- ];
68
- return values.some((value) => normalizedSearchText(value).includes(query));
69
- }
70
-
71
- function matchesOutlineStatus(chapter, status) {
72
- if (status === "all") return true;
73
- if (status === "empty") return !chapter?.outline;
74
- return chapter?.outline?.status === status;
75
- }
76
-
77
- function matchesForeshadowStatus(chapter, status) {
78
- if (status === "all") return true;
79
- const foreshadows = Array.isArray(chapter?.foreshadows) ? chapter.foreshadows : [];
80
- if (status === "none") return foreshadows.length === 0;
81
- if (status === "unresolved") {
82
- return foreshadows.some((item) => item?.status === "planned" || item?.status === "planted");
83
- }
84
- return foreshadows.some((item) => item?.status === status);
85
- }
86
-
87
15
  export function normalizeOutlineBoardState(value = {}) {
88
16
  const outlineStatus = text(value?.outlineStatus);
89
17
  const foreshadowStatus = text(value?.foreshadowStatus);
90
18
  const sort = text(value?.sort);
91
19
  return {
92
- query: text(value?.query),
20
+ query: text(value?.query).slice(0, 200),
93
21
  volumeId: text(value?.volumeId),
94
22
  outlineStatus: outlineStatuses.has(outlineStatus) ? outlineStatus : "all",
95
23
  foreshadowStatus: foreshadowStatuses.has(foreshadowStatus) ? foreshadowStatus : "all",
@@ -97,39 +25,17 @@ export function normalizeOutlineBoardState(value = {}) {
97
25
  };
98
26
  }
99
27
 
100
- /**
101
- * 保持分卷层级与默认章节树顺序,对章节执行筛选和卷内排序。
102
- * 未筛选时保留空分卷;指定空分卷时也保留该分组,便于确认数据状态。
103
- */
104
- export function prepareOutlineBoard(board, value = {}) {
28
+ export function outlineBoardRequestPath(workId, value = {}, page = 1, limit = 30) {
105
29
  const state = normalizeOutlineBoardState(value);
106
- const query = normalizedSearchText(state.query);
107
- const chapterFilterActive = Boolean(query || state.outlineStatus !== "all" || state.foreshadowStatus !== "all");
108
- const volumeFilterActive = Boolean(state.volumeId);
109
- const sourceVolumes = Array.isArray(board?.volumes) ? board.volumes : [];
110
- const totalChapterCount = sourceVolumes.reduce(
111
- (total, volume) => total + (Array.isArray(volume?.chapters) ? volume.chapters.length : 0),
112
- 0
113
- );
114
- const volumes = [...sourceVolumes].sort(treeCompare).flatMap((volume) => {
115
- if (volumeFilterActive && text(volume?.id) !== state.volumeId) return [];
116
- const sourceChapters = Array.isArray(volume?.chapters) ? volume.chapters : [];
117
- const chapters = sourceChapters
118
- .filter((chapter) => matchesQuery(chapter, query)
119
- && matchesOutlineStatus(chapter, state.outlineStatus)
120
- && matchesForeshadowStatus(chapter, state.foreshadowStatus))
121
- .sort((left, right) => compareChapters(left, right, state.sort));
122
- const keepEmptyVolume = sourceChapters.length === 0 && (!chapterFilterActive || state.volumeId === text(volume?.id));
123
- if (chapters.length === 0 && !keepEmptyVolume) return [];
124
- return [{ ...volume, chapters }];
125
- });
126
- return {
127
- state,
128
- volumes,
129
- totalChapterCount,
130
- visibleChapterCount: volumes.reduce((total, volume) => total + volume.chapters.length, 0),
131
- filtersActive: chapterFilterActive || volumeFilterActive
132
- };
30
+ const safePage = Number.isInteger(Number(page)) && Number(page) > 0 ? Number(page) : 1;
31
+ const safeLimit = Number.isInteger(Number(limit)) && Number(limit) >= 1 && Number(limit) <= 100 ? Number(limit) : 30;
32
+ const params = new URLSearchParams({ page: String(safePage), limit: String(safeLimit) });
33
+ if (state.query.trim()) params.set("q", state.query.trim());
34
+ if (state.volumeId) params.set("volumeId", state.volumeId);
35
+ if (state.outlineStatus !== "all") params.set("outlineStatus", state.outlineStatus);
36
+ if (state.foreshadowStatus !== "all") params.set("foreshadowStatus", state.foreshadowStatus);
37
+ if (state.sort !== "tree") params.set("sort", state.sort);
38
+ return `/api/works/${encodeURIComponent(text(workId))}/outline-board?${params.toString()}`;
133
39
  }
134
40
 
135
41
  export function outlineBoardUnresolvedCount(chapter) {
@@ -94,7 +94,7 @@
94
94
  [data-pending-view="settings"] .auth-pending #settings-hub-view.hidden,
95
95
  [data-pending-view="platform-ai"] .auth-pending #platform-ai-view.hidden,
96
96
  [data-pending-view="platform-usage"] .auth-pending #platform-usage-view.hidden,
97
- [data-pending-view="work-audit"] .auth-pending #work-audit-view.hidden,
97
+ [data-pending-view="work-audit"] .auth-pending #work-audit-view.hidden { display: flex !important; }
98
98
  [data-pending-view="module"] .auth-pending #module-view.hidden { display: block !important; }
99
99
  [data-pending-view="welcome"] .auth-pending #welcome-view.hidden,
100
100
  [data-pending-view="editor"] .auth-pending #editor-view.hidden { display: grid !important; }
@@ -3578,7 +3578,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3578
3578
  #shelf-view .product-footer, #settings-hub-view .product-footer { margin-bottom: 0; padding-bottom: env(safe-area-inset-bottom); }
3579
3579
  .shelf-header, .module-header { align-items: stretch; flex-direction: column; gap: 14px; margin-bottom: 20px; }
3580
3580
  .shelf-header h1 { font-size: clamp(24px, 7.5vw, 32px); }
3581
- .shelf-header .primary-button { align-self: flex-start; min-width: 150px; min-height: 40px; padding: 10px 18px; }
3581
+ .shelf-header .primary-button { align-self: flex-start; min-width: 150px; min-height: 40px; padding: 8px 18px; }
3582
3582
  .shelf-header-actions { align-self: flex-start; flex-wrap: wrap; }
3583
3583
  .settings-detail-actions { align-items: stretch; flex-direction: column; }
3584
3584
  .settings-detail-actions .primary-button, .settings-detail-actions .settings-parent-button { align-self: stretch; width: 100%; }
package/dist/store.js CHANGED
@@ -155,6 +155,9 @@ function settingsFromKnowledgeSections(sections) {
155
155
  return sections.map((section) => section.contentMarkdown).filter((content) => content.trim());
156
156
  }
157
157
  const CHAPTER_OUTLINE_BOARD_PREVIEW_LENGTH = 600;
158
+ function chapterOutlineBoardLikePattern(value) {
159
+ return `%${value.normalize("NFKC").toLocaleLowerCase("zh-CN").replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
160
+ }
158
161
  export const versionedEntityTypes = [
159
162
  "work",
160
163
  "volume",
@@ -1013,20 +1016,24 @@ export class Store {
1013
1016
  return this.getWork(workId);
1014
1017
  }
1015
1018
  deleteWork(workId, expectedVersionNo) {
1016
- this.db.transaction(() => {
1019
+ return this.db.transaction(() => {
1017
1020
  const current = this.getWork(workId);
1018
1021
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
1019
1022
  const timestamp = now();
1023
+ const activeTaskIds = this.db.all("SELECT id FROM analysis_tasks WHERE work_id = ? AND status IN ('pending', 'running') ORDER BY id", workId).map((row) => requiredString(row, "id"));
1020
1024
  const versionNo = this.recordEntityVersion("work", workId, "delete", null, "删除作品(可恢复)", timestamp);
1021
1025
  this.db.run("UPDATE works SET version_no = ?, deleted_at = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, timestamp, workId);
1026
+ this.db.run(`UPDATE analysis_tasks SET status = 'expired', next_attempt_at = NULL, updated_at = ?
1027
+ WHERE work_id = ? AND status IN ('pending', 'running')`, timestamp, workId);
1028
+ this.db.run("DELETE FROM relationship_source_index_queue WHERE work_id = ?", workId);
1022
1029
  this.audit(workId, "work.deleted", "work", workId, {
1023
1030
  title: current.title,
1024
1031
  versionNo,
1025
1032
  recoverable: true,
1026
1033
  expiresAt: recycleBinExpiresAt(timestamp)
1027
1034
  });
1035
+ return activeTaskIds;
1028
1036
  });
1029
- return [];
1030
1037
  }
1031
1038
  restoreWork(workId, expectedVersionNo) {
1032
1039
  const deleted = this.db.get("SELECT * FROM works WHERE id = ? AND deleted_at IS NOT NULL", workId);
@@ -2520,10 +2527,101 @@ export class Store {
2520
2527
  return null;
2521
2528
  return this.mapChapterOutline(row, chapter);
2522
2529
  }
2523
- getChapterOutlineBoard(workId) {
2530
+ getChapterOutlineBoard(workId, filters, pagination) {
2524
2531
  this.getWork(workId);
2525
2532
  const previewLength = CHAPTER_OUTLINE_BOARD_PREVIEW_LENGTH;
2526
- const rows = this.db.all(`SELECT volume.id AS volume_id, volume.title AS volume_title, volume.sort_order AS volume_order,
2533
+ const where = [
2534
+ "chapter.work_id = ?",
2535
+ "chapter.deleted_at IS NULL",
2536
+ "volume.deleted_at IS NULL"
2537
+ ];
2538
+ const whereParams = [workId];
2539
+ if (filters.volumeId) {
2540
+ where.push("chapter.volume_id = ?");
2541
+ whereParams.push(filters.volumeId);
2542
+ }
2543
+ if (filters.outlineStatus === "empty") {
2544
+ where.push("outline.chapter_id IS NULL");
2545
+ }
2546
+ else if (filters.outlineStatus !== "all") {
2547
+ where.push("outline.status = ?");
2548
+ whereParams.push(filters.outlineStatus);
2549
+ }
2550
+ const associatedForeshadow = (statusSql = "") => `(
2551
+ EXISTS (
2552
+ SELECT 1 FROM foreshadow_occurrences occurrence
2553
+ JOIN foreshadows foreshadow ON foreshadow.id = occurrence.foreshadow_id
2554
+ WHERE occurrence.chapter_id = chapter.id AND foreshadow.work_id = chapter.work_id${statusSql}
2555
+ ) OR EXISTS (
2556
+ SELECT 1 FROM foreshadows foreshadow
2557
+ WHERE foreshadow.work_id = chapter.work_id
2558
+ AND foreshadow.planned_payoff_chapter_id = chapter.id${statusSql}
2559
+ )
2560
+ )`;
2561
+ if (filters.foreshadowStatus === "none") {
2562
+ where.push(`NOT ${associatedForeshadow()}`);
2563
+ }
2564
+ else if (filters.foreshadowStatus !== "all") {
2565
+ const statusSql = filters.foreshadowStatus === "unresolved"
2566
+ ? " AND foreshadow.status IN ('planned', 'planted')"
2567
+ : filters.foreshadowStatus === "resolved"
2568
+ ? " AND foreshadow.status = 'resolved'"
2569
+ : " AND foreshadow.status = 'abandoned'";
2570
+ where.push(associatedForeshadow(statusSql));
2571
+ }
2572
+ const trimmedQuery = filters.query.trim();
2573
+ if (trimmedQuery) {
2574
+ const pattern = chapterOutlineBoardLikePattern(trimmedQuery);
2575
+ where.push(`(
2576
+ lower(chapter.title) LIKE ? ESCAPE '\\'
2577
+ OR lower(chapter.chapter_type) LIKE ? ESCAPE '\\'
2578
+ OR lower(outline.goal) LIKE ? ESCAPE '\\'
2579
+ OR lower(outline.conflict) LIKE ? ESCAPE '\\'
2580
+ OR lower(outline.turning_point) LIKE ? ESCAPE '\\'
2581
+ OR lower(outline.notes) LIKE ? ESCAPE '\\'
2582
+ OR EXISTS (
2583
+ SELECT 1 FROM foreshadow_occurrences occurrence
2584
+ JOIN foreshadows foreshadow ON foreshadow.id = occurrence.foreshadow_id
2585
+ WHERE occurrence.chapter_id = chapter.id AND foreshadow.work_id = chapter.work_id
2586
+ AND lower(foreshadow.title) LIKE ? ESCAPE '\\'
2587
+ )
2588
+ OR EXISTS (
2589
+ SELECT 1 FROM foreshadows foreshadow
2590
+ WHERE foreshadow.work_id = chapter.work_id
2591
+ AND foreshadow.planned_payoff_chapter_id = chapter.id
2592
+ AND lower(foreshadow.title) LIKE ? ESCAPE '\\'
2593
+ )
2594
+ )`);
2595
+ whereParams.push(...Array.from({ length: 8 }, () => pattern));
2596
+ }
2597
+ const whereSql = where.join(" AND ");
2598
+ const associationCount = (statusSql = "") => `(
2599
+ SELECT COUNT(*) FROM foreshadows sorted_foreshadow
2600
+ WHERE sorted_foreshadow.work_id = chapter.work_id${statusSql}
2601
+ AND (
2602
+ sorted_foreshadow.planned_payoff_chapter_id = chapter.id
2603
+ OR EXISTS (
2604
+ SELECT 1 FROM foreshadow_occurrences sorted_occurrence
2605
+ WHERE sorted_occurrence.foreshadow_id = sorted_foreshadow.id
2606
+ AND sorted_occurrence.chapter_id = chapter.id
2607
+ )
2608
+ )
2609
+ )`;
2610
+ const chapterTreeOrder = "chapter.sort_order, chapter.created_at, chapter.id";
2611
+ const chapterOrder = filters.sort === "status"
2612
+ ? `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}`
2613
+ : filters.sort === "foreshadows"
2614
+ ? `${associationCount(" AND sorted_foreshadow.status IN ('planned', 'planted')")} DESC, ${associationCount()} DESC, ${chapterTreeOrder}`
2615
+ : filters.sort === "title"
2616
+ ? `chapter.title COLLATE NOCASE, ${chapterTreeOrder}`
2617
+ : chapterTreeOrder;
2618
+ const orderSql = `volume.sort_order, volume.created_at, volume.id, ${chapterOrder}`;
2619
+ const total = numberValue(this.db.get(`SELECT COUNT(*) AS count
2620
+ FROM chapters chapter
2621
+ JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
2622
+ LEFT JOIN chapter_outlines outline ON outline.chapter_id = chapter.id
2623
+ WHERE ${whereSql}`, ...whereParams) ?? {}, "count");
2624
+ const pageRows = this.db.all(`SELECT volume.id AS volume_id, volume.title AS volume_title, volume.sort_order AS volume_order,
2527
2625
  chapter.id AS chapter_id, chapter.title AS chapter_title, chapter.chapter_type,
2528
2626
  chapter.sort_order AS chapter_order,
2529
2627
  outline.chapter_id AS outline_chapter_id,
@@ -2532,34 +2630,59 @@ export class Store {
2532
2630
  substr(outline.turning_point, 1, ?) AS turning_point, length(outline.turning_point) > ? AS turning_point_truncated,
2533
2631
  substr(outline.notes, 1, ?) AS notes, length(outline.notes) > ? AS notes_truncated,
2534
2632
  outline.status, outline.updated_at AS outline_updated_at
2633
+ FROM chapters chapter
2634
+ JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
2635
+ LEFT JOIN chapter_outlines outline ON outline.chapter_id = chapter.id
2636
+ WHERE ${whereSql}
2637
+ ORDER BY ${orderSql}
2638
+ LIMIT ? OFFSET ?`, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, ...whereParams, pagination.limit + 1, pagination.offset);
2639
+ const chapterPage = paginated(pageRows, pagination, total);
2640
+ const volumeRows = this.db.all(`SELECT volume.id, volume.title, volume.sort_order,
2641
+ COUNT(chapter.id) AS chapter_count
2535
2642
  FROM volumes volume
2536
2643
  LEFT JOIN chapters chapter
2537
2644
  ON chapter.volume_id = volume.id AND chapter.work_id = volume.work_id AND chapter.deleted_at IS NULL
2538
- LEFT JOIN chapter_outlines outline ON outline.chapter_id = chapter.id
2539
2645
  WHERE volume.work_id = ? AND volume.deleted_at IS NULL
2540
- ORDER BY volume.sort_order, volume.created_at, volume.id,
2541
- chapter.sort_order, chapter.created_at, chapter.id`, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, workId);
2646
+ GROUP BY volume.id
2647
+ ORDER BY volume.sort_order, volume.created_at, volume.id`, workId);
2648
+ const filteredVolumeRows = this.db.all(`SELECT chapter.volume_id, COUNT(*) AS chapter_count
2649
+ FROM chapters chapter
2650
+ JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
2651
+ LEFT JOIN chapter_outlines outline ON outline.chapter_id = chapter.id
2652
+ WHERE ${whereSql}
2653
+ GROUP BY chapter.volume_id`, ...whereParams);
2654
+ const filteredCountByVolume = new Map(filteredVolumeRows.map((row) => [
2655
+ requiredString(row, "volume_id"),
2656
+ numberValue(row, "chapter_count")
2657
+ ]));
2658
+ const volumeOptions = volumeRows.map((row) => ({
2659
+ id: requiredString(row, "id"),
2660
+ title: requiredString(row, "title"),
2661
+ sortOrder: numberValue(row, "sort_order"),
2662
+ chapterCount: numberValue(row, "chapter_count"),
2663
+ filteredChapterCount: filteredCountByVolume.get(requiredString(row, "id")) ?? 0
2664
+ }));
2665
+ const volumeOptionById = new Map(volumeOptions.map((volume) => [volume.id, volume]));
2666
+ const volumeOrderById = new Map(volumeOptions.map((volume, index) => [volume.id, index]));
2542
2667
  const volumeById = new Map();
2543
2668
  const chapterById = new Map();
2544
- let outlinedChapterCount = 0;
2545
- for (const row of rows) {
2669
+ for (const row of chapterPage.items) {
2546
2670
  const volumeId = requiredString(row, "volume_id");
2547
2671
  let volume = volumeById.get(volumeId);
2548
2672
  if (!volume) {
2673
+ const summary = volumeOptionById.get(volumeId);
2549
2674
  volume = {
2550
2675
  id: volumeId,
2551
2676
  title: requiredString(row, "volume_title"),
2552
2677
  sortOrder: numberValue(row, "volume_order"),
2678
+ chapterCount: summary?.chapterCount ?? 0,
2679
+ filteredChapterCount: summary?.filteredChapterCount ?? 0,
2553
2680
  chapters: []
2554
2681
  };
2555
2682
  volumeById.set(volumeId, volume);
2556
2683
  }
2557
- const chapterId = optionalString(row, "chapter_id");
2558
- if (!chapterId)
2559
- continue;
2684
+ const chapterId = requiredString(row, "chapter_id");
2560
2685
  const hasOutline = optionalString(row, "outline_chapter_id") !== null;
2561
- if (hasOutline)
2562
- outlinedChapterCount += 1;
2563
2686
  const chapter = {
2564
2687
  id: chapterId,
2565
2688
  title: requiredString(row, "chapter_title"),
@@ -2580,6 +2703,7 @@ export class Store {
2580
2703
  volume.chapters.push(chapter);
2581
2704
  chapterById.set(chapterId, chapter);
2582
2705
  }
2706
+ const chapterIds = [...chapterById.keys()];
2583
2707
  const associations = new Map();
2584
2708
  const associate = (chapterId, source, role, plannedPayoff = false) => {
2585
2709
  if (!chapterId || !chapterById.has(chapterId))
@@ -2597,28 +2721,29 @@ export class Store {
2597
2721
  byForeshadow.set(source.id, summary);
2598
2722
  associations.set(chapterId, byForeshadow);
2599
2723
  };
2600
- const foreshadowRows = this.db.all(`SELECT foreshadow.id, foreshadow.title, foreshadow.status, foreshadow.importance,
2601
- occurrence_chapter.id AS occurrence_chapter_id, occurrence_volume.id AS occurrence_volume_id,
2602
- occurrence.role, payoff_chapter.id AS payoff_chapter_id, payoff_volume.id AS payoff_volume_id
2603
- FROM foreshadows foreshadow
2604
- LEFT JOIN foreshadow_occurrences occurrence ON occurrence.foreshadow_id = foreshadow.id
2605
- LEFT JOIN chapters occurrence_chapter
2606
- ON occurrence_chapter.id = occurrence.chapter_id
2607
- AND occurrence_chapter.work_id = foreshadow.work_id
2608
- AND occurrence_chapter.deleted_at IS NULL
2609
- LEFT JOIN volumes occurrence_volume
2610
- ON occurrence_volume.id = occurrence_chapter.volume_id AND occurrence_volume.deleted_at IS NULL
2611
- LEFT JOIN chapters payoff_chapter
2612
- ON payoff_chapter.id = foreshadow.planned_payoff_chapter_id
2613
- AND payoff_chapter.work_id = foreshadow.work_id
2614
- AND payoff_chapter.deleted_at IS NULL
2615
- LEFT JOIN volumes payoff_volume
2616
- ON payoff_volume.id = payoff_chapter.volume_id AND payoff_volume.deleted_at IS NULL
2617
- WHERE foreshadow.work_id = ?
2618
- ORDER BY CASE foreshadow.importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
2619
- foreshadow.created_at, foreshadow.id, occurrence.created_at, occurrence.id`, workId);
2620
- const foreshadowIds = new Set();
2621
- const unresolvedForeshadowIds = new Set();
2724
+ const placeholders = chapterIds.map(() => "?").join(", ");
2725
+ const foreshadowRows = chapterIds.length === 0 ? [] : this.db.all(`SELECT association.* FROM (
2726
+ SELECT chapter.id AS chapter_id, foreshadow.id, foreshadow.title, foreshadow.status,
2727
+ foreshadow.importance, foreshadow.created_at AS foreshadow_created_at,
2728
+ occurrence.role, 0 AS planned_payoff, occurrence.created_at AS association_created_at,
2729
+ occurrence.id AS association_id
2730
+ FROM chapters chapter
2731
+ JOIN foreshadow_occurrences occurrence ON occurrence.chapter_id = chapter.id
2732
+ JOIN foreshadows foreshadow ON foreshadow.id = occurrence.foreshadow_id
2733
+ WHERE chapter.id IN (${placeholders}) AND chapter.work_id = ? AND chapter.deleted_at IS NULL
2734
+ AND foreshadow.work_id = ?
2735
+ UNION ALL
2736
+ SELECT chapter.id AS chapter_id, foreshadow.id, foreshadow.title, foreshadow.status,
2737
+ foreshadow.importance, foreshadow.created_at AS foreshadow_created_at,
2738
+ NULL AS role, 1 AS planned_payoff, foreshadow.created_at AS association_created_at,
2739
+ foreshadow.id AS association_id
2740
+ FROM chapters chapter
2741
+ JOIN foreshadows foreshadow ON foreshadow.planned_payoff_chapter_id = chapter.id
2742
+ WHERE chapter.id IN (${placeholders}) AND chapter.work_id = ? AND chapter.deleted_at IS NULL
2743
+ AND foreshadow.work_id = ?
2744
+ ) association
2745
+ ORDER BY CASE association.importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
2746
+ association.foreshadow_created_at, association.id, association.association_created_at, association.association_id`, ...chapterIds, workId, workId, ...chapterIds, workId, workId);
2622
2747
  for (const row of foreshadowRows) {
2623
2748
  const source = {
2624
2749
  id: requiredString(row, "id"),
@@ -2626,15 +2751,8 @@ export class Store {
2626
2751
  status: requiredString(row, "status"),
2627
2752
  importance: requiredString(row, "importance")
2628
2753
  };
2629
- foreshadowIds.add(source.id);
2630
- if (source.status === "planned" || source.status === "planted")
2631
- unresolvedForeshadowIds.add(source.id);
2632
- const occurrenceChapterId = optionalString(row, "occurrence_volume_id")
2633
- ? optionalString(row, "occurrence_chapter_id")
2634
- : null;
2635
2754
  const role = optionalString(row, "role");
2636
- associate(occurrenceChapterId, source, role === "setup" || role === "reminder" || role === "payoff" ? role : undefined);
2637
- associate(optionalString(row, "payoff_volume_id") ? optionalString(row, "payoff_chapter_id") : null, source, undefined, true);
2755
+ associate(requiredString(row, "chapter_id"), source, role === "setup" || role === "reminder" || role === "payoff" ? role : undefined, booleanValue(row, "planned_payoff"));
2638
2756
  }
2639
2757
  for (const [chapterId, byForeshadow] of associations) {
2640
2758
  const chapter = chapterById.get(chapterId);
@@ -2645,14 +2763,47 @@ export class Store {
2645
2763
  roles: [...summary.roles]
2646
2764
  }));
2647
2765
  }
2766
+ const chapterFiltersActive = Boolean(trimmedQuery || filters.outlineStatus !== "all" || filters.foreshadowStatus !== "all");
2767
+ if (pagination.page === 1) {
2768
+ for (const option of volumeOptions) {
2769
+ if (option.chapterCount !== 0)
2770
+ continue;
2771
+ if (filters.volumeId && option.id !== filters.volumeId)
2772
+ continue;
2773
+ if (chapterFiltersActive && filters.volumeId !== option.id)
2774
+ continue;
2775
+ volumeById.set(option.id, { ...option, chapters: [] });
2776
+ }
2777
+ }
2778
+ const volumes = [...volumeById.values()].sort((left, right) => ((volumeOrderById.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (volumeOrderById.get(right.id) ?? Number.MAX_SAFE_INTEGER)));
2779
+ const statsRow = this.db.get(`SELECT
2780
+ (SELECT COUNT(*) FROM chapters chapter
2781
+ JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
2782
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL) AS chapter_count,
2783
+ (SELECT COUNT(*) FROM chapter_outlines outline
2784
+ JOIN chapters chapter ON chapter.id = outline.chapter_id
2785
+ JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
2786
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL) AS outlined_chapter_count,
2787
+ (SELECT COUNT(*) FROM foreshadows foreshadow WHERE foreshadow.work_id = ?) AS foreshadow_count,
2788
+ (SELECT COUNT(*) FROM foreshadows foreshadow
2789
+ WHERE foreshadow.work_id = ? AND foreshadow.status IN ('planned', 'planted')) AS unresolved_foreshadow_count`, workId, workId, workId, workId) ?? {};
2648
2790
  return {
2649
2791
  workId,
2650
- volumes: [...volumeById.values()],
2792
+ volumes,
2793
+ volumeOptions,
2794
+ filters: { ...filters, query: trimmedQuery },
2795
+ page: chapterPage.page,
2796
+ limit: chapterPage.limit,
2797
+ itemCount: chapterById.size,
2798
+ total,
2799
+ pageCount: Math.max(1, Math.ceil(total / pagination.limit)),
2800
+ hasMore: chapterPage.hasMore,
2801
+ nextPage: chapterPage.nextPage,
2651
2802
  stats: {
2652
- chapterCount: chapterById.size,
2653
- outlinedChapterCount,
2654
- foreshadowCount: foreshadowIds.size,
2655
- unresolvedForeshadowCount: unresolvedForeshadowIds.size
2803
+ chapterCount: numberValue(statsRow, "chapter_count"),
2804
+ outlinedChapterCount: numberValue(statsRow, "outlined_chapter_count"),
2805
+ foreshadowCount: numberValue(statsRow, "foreshadow_count"),
2806
+ unresolvedForeshadowCount: numberValue(statsRow, "unresolved_foreshadow_count")
2656
2807
  }
2657
2808
  };
2658
2809
  }
@@ -5812,7 +5963,10 @@ export class Store {
5812
5963
  return numberValue(row ?? {}, "value");
5813
5964
  }
5814
5965
  listAutoRunWorkIds() {
5815
- return this.db.all("SELECT work_id FROM work_ai_settings WHERE auto_run_enabled = 1 ORDER BY work_id").map((row) => requiredString(row, "work_id"));
5966
+ return this.db.all(`SELECT settings.work_id FROM work_ai_settings settings
5967
+ JOIN works work ON work.id = settings.work_id
5968
+ WHERE settings.auto_run_enabled = 1 AND work.deleted_at IS NULL
5969
+ ORDER BY settings.work_id`).map((row) => requiredString(row, "work_id"));
5816
5970
  }
5817
5971
  claimPendingTask(taskId, runningLimit) {
5818
5972
  return this.db.transaction(() => {
@@ -7185,24 +7339,46 @@ export class Store {
7185
7339
  });
7186
7340
  }
7187
7341
  async exportEpub(workId, volumeId) {
7188
- const tree = this.getWorkTree(workId);
7189
- const allVolumes = tree.volumes;
7190
- const selectedVolume = volumeId ? allVolumes.find((volume) => String(volume.id) === volumeId) : undefined;
7342
+ const work = this.getWork(workId);
7343
+ const allVolumeRows = this.db.all("SELECT id, title FROM volumes WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
7344
+ const selectedVolume = volumeId ? allVolumeRows.find((volume) => requiredString(volume, "id") === volumeId) : undefined;
7191
7345
  if (volumeId && !selectedVolume)
7192
7346
  throw notFound("分卷");
7193
- const sourceVolumes = selectedVolume ? [selectedVolume] : allVolumes;
7194
- const title = selectedVolume ? `${String(tree.title)} - ${String(selectedVolume.title)}` : String(tree.title);
7347
+ const sourceVolumeRows = selectedVolume ? [selectedVolume] : allVolumeRows;
7348
+ const chapterRows = volumeId
7349
+ ? this.db.all(`SELECT id, volume_id, title, version_no FROM chapters
7350
+ WHERE work_id = ? AND volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at`, workId, volumeId)
7351
+ : this.db.all(`SELECT id, volume_id, title, version_no FROM chapters
7352
+ WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at`, workId);
7353
+ const chaptersByVolume = new Map();
7354
+ for (const chapter of chapterRows) {
7355
+ const chapterVolumeId = requiredString(chapter, "volume_id");
7356
+ const chapters = chaptersByVolume.get(chapterVolumeId) ?? [];
7357
+ chapters.push(chapter);
7358
+ chaptersByVolume.set(chapterVolumeId, chapters);
7359
+ }
7360
+ const title = selectedVolume ? `${String(work.title)} - ${requiredString(selectedVolume, "title")}` : String(work.title);
7195
7361
  const cover = this.findWorkCover(workId);
7196
7362
  const archive = await createEpubArchive({
7197
7363
  title,
7198
- author: String(tree.author ?? ""),
7199
- description: String(tree.description ?? ""),
7200
- language: String(tree.language ?? "zh-CN"),
7201
- volumes: sourceVolumes.map((volume) => ({
7202
- title: String(volume.title),
7203
- chapters: volume.chapters.map((chapter) => ({
7204
- title: String(chapter.title),
7205
- content: String(chapter.content ?? "")
7364
+ author: String(work.author ?? ""),
7365
+ description: String(work.description ?? ""),
7366
+ language: String(work.language ?? "zh-CN"),
7367
+ volumes: sourceVolumeRows.map((volume) => ({
7368
+ title: requiredString(volume, "title"),
7369
+ chapters: (chaptersByVolume.get(requiredString(volume, "id")) ?? []).map((chapter) => ({
7370
+ title: requiredString(chapter, "title"),
7371
+ content: () => {
7372
+ const chapterId = requiredString(chapter, "id");
7373
+ const versionNo = numberValue(chapter, "version_no");
7374
+ const current = this.db.get("SELECT content FROM chapters WHERE id = ? AND work_id = ? AND version_no = ? AND deleted_at IS NULL", chapterId, workId, versionNo);
7375
+ if (current)
7376
+ return requiredString(current, "content");
7377
+ const historical = this.db.get("SELECT content FROM chapter_versions WHERE chapter_id = ? AND work_id = ? AND version_no = ?", chapterId, workId, versionNo);
7378
+ if (!historical)
7379
+ throw new AppError(409, "EPUB_EXPORT_SOURCE_CHANGED", "导出过程中章节版本已不可用,请重新导出");
7380
+ return requiredString(historical, "content");
7381
+ }
7206
7382
  }))
7207
7383
  })),
7208
7384
  cover: cover ? { mimeType: cover.mimeType, content: cover.content } : null