@musnows/scriverse 0.7.2 → 0.7.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/README.en.md +3 -0
- package/README.md +8 -0
- package/dist/ai-connectivity-test.js +109 -0
- package/dist/ai-connectivity-test.js.map +1 -0
- package/dist/ai-conversation-export.js +70 -0
- package/dist/ai-conversation-export.js.map +1 -0
- package/dist/ai-stream-timeout.js +18 -0
- package/dist/ai-stream-timeout.js.map +1 -0
- package/dist/ai.js +926 -354
- package/dist/ai.js.map +1 -1
- package/dist/app.js +498 -80
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +28 -12
- package/dist/attachment-storage.js.map +1 -1
- package/dist/backup-encryption.js +129 -0
- package/dist/backup-encryption.js.map +1 -0
- package/dist/character-extraction.js +133 -0
- package/dist/character-extraction.js.map +1 -0
- package/dist/cli-core.js +7 -6
- package/dist/cli-core.js.map +1 -1
- package/dist/collaboration-presence.js +200 -18
- package/dist/collaboration-presence.js.map +1 -1
- package/dist/database.js +335 -3
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +1 -1
- package/dist/docx-export.js.map +1 -1
- package/dist/domain.js +18 -0
- package/dist/domain.js.map +1 -1
- package/dist/epub-export.js +319 -0
- package/dist/epub-export.js.map +1 -0
- package/dist/hybrid-search.js +8 -0
- package/dist/hybrid-search.js.map +1 -1
- package/dist/image-metadata.js +17 -2
- package/dist/image-metadata.js.map +1 -1
- package/dist/presence-store.js +160 -0
- package/dist/presence-store.js.map +1 -0
- package/dist/public/ai-connectivity-test.d.ts +7 -0
- package/dist/public/ai-connectivity-test.js +82 -0
- package/dist/public/ai-context-meter.js +27 -0
- package/dist/public/ai-mentions.js +14 -0
- package/dist/public/ai-request-manager.js +99 -0
- package/dist/public/ai-stream-protocol.js +51 -0
- package/dist/public/app.js +3587 -559
- package/dist/public/background-task-center.js +1 -1
- package/dist/public/chapter-editor-virtualization.js +52 -0
- package/dist/public/chapter-version-diff.d.ts +20 -0
- package/dist/public/chapter-version-diff.js +116 -0
- package/dist/public/foreshadow-reminder.d.ts +32 -0
- package/dist/public/foreshadow-reminder.js +73 -0
- package/dist/public/global-replace-refresh.js +60 -0
- package/dist/public/index.html +161 -31
- package/dist/public/outline-board.d.ts +61 -0
- package/dist/public/outline-board.js +137 -0
- package/dist/public/page-route.d.ts +1 -0
- package/dist/public/page-route.js +8 -0
- package/dist/public/presence-client-id.d.ts +10 -0
- package/dist/public/presence-client-id.js +31 -0
- package/dist/public/reading-preview.d.ts +32 -0
- package/dist/public/reading-preview.js +136 -0
- package/dist/public/s3-backup-ui.d.ts +10 -0
- package/dist/public/s3-backup-ui.js +29 -0
- package/dist/public/setting-filters.d.ts +16 -0
- package/dist/public/setting-filters.js +19 -0
- package/dist/public/styles.css +522 -11
- package/dist/public/upload-progress.d.ts +2 -0
- package/dist/public/upload-progress.js +10 -0
- package/dist/s3-backup.js +197 -12
- package/dist/s3-backup.js.map +1 -1
- package/dist/security.js +86 -21
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +38 -18
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +899 -104
- package/dist/store.js.map +1 -1
- package/dist/upload-limits.js +35 -0
- package/dist/upload-limits.js.map +1 -0
- package/dist/user-auth.js +56 -10
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +3 -0
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const outlineStatuses = new Set(["all", "empty", "draft", "ready", "completed"]);
|
|
2
|
+
const foreshadowStatuses = new Set(["all", "none", "unresolved", "resolved", "abandoned"]);
|
|
3
|
+
const sortModes = new Set(["tree", "status", "foreshadows", "title"]);
|
|
4
|
+
|
|
5
|
+
function text(value) {
|
|
6
|
+
return String(value ?? "");
|
|
7
|
+
}
|
|
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
|
+
function unresolvedForeshadowCount(chapter) {
|
|
33
|
+
return (Array.isArray(chapter?.foreshadows) ? chapter.foreshadows : [])
|
|
34
|
+
.filter((foreshadow) => foreshadow?.status === "planned" || foreshadow?.status === "planted")
|
|
35
|
+
.length;
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
export function normalizeOutlineBoardState(value = {}) {
|
|
88
|
+
const outlineStatus = text(value?.outlineStatus);
|
|
89
|
+
const foreshadowStatus = text(value?.foreshadowStatus);
|
|
90
|
+
const sort = text(value?.sort);
|
|
91
|
+
return {
|
|
92
|
+
query: text(value?.query),
|
|
93
|
+
volumeId: text(value?.volumeId),
|
|
94
|
+
outlineStatus: outlineStatuses.has(outlineStatus) ? outlineStatus : "all",
|
|
95
|
+
foreshadowStatus: foreshadowStatuses.has(foreshadowStatus) ? foreshadowStatus : "all",
|
|
96
|
+
sort: sortModes.has(sort) ? sort : "tree"
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 保持分卷层级与默认章节树顺序,对章节执行筛选和卷内排序。
|
|
102
|
+
* 未筛选时保留空分卷;指定空分卷时也保留该分组,便于确认数据状态。
|
|
103
|
+
*/
|
|
104
|
+
export function prepareOutlineBoard(board, value = {}) {
|
|
105
|
+
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
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function outlineBoardUnresolvedCount(chapter) {
|
|
136
|
+
return unresolvedForeshadowCount(chapter);
|
|
137
|
+
}
|
|
@@ -2,6 +2,7 @@ export type RestorableModule = "drafts" | "settings" | "characters" | "races" |
|
|
|
2
2
|
export type PageRoute =
|
|
3
3
|
| { view: "shelf" }
|
|
4
4
|
| { view: "editor"; workId: string; chapterId: string | null }
|
|
5
|
+
| { view: "reader"; workId: string; chapterId: string | null }
|
|
5
6
|
| { view: "module"; workId: string; module: RestorableModule }
|
|
6
7
|
| { view: "welcome"; workId: string }
|
|
7
8
|
| { view: "settings" | "platform-ai"; workId: string | null; returnView?: "shelf" | "editor" | "module" | "welcome"; returnModule?: RestorableModule; returnChapterId?: string };
|
|
@@ -40,6 +40,10 @@ export function serializePageRoute(route = {}) {
|
|
|
40
40
|
params.set("view", "editor");
|
|
41
41
|
params.set("work", workId);
|
|
42
42
|
if (route.chapterId) params.set("chapter", String(route.chapterId));
|
|
43
|
+
} else if (view === "reader" && workId) {
|
|
44
|
+
params.set("view", "reader");
|
|
45
|
+
params.set("work", workId);
|
|
46
|
+
if (route.chapterId) params.set("chapter", String(route.chapterId));
|
|
43
47
|
} else if (view === "module" && workId && moduleSet.has(route.module)) {
|
|
44
48
|
params.set("view", "module");
|
|
45
49
|
params.set("work", workId);
|
|
@@ -75,6 +79,10 @@ export function parsePageRoute(hash = "") {
|
|
|
75
79
|
const chapterId = value(params, "chapter");
|
|
76
80
|
return { view, workId, chapterId: chapterId || null };
|
|
77
81
|
}
|
|
82
|
+
if (view === "reader" && workId) {
|
|
83
|
+
const chapterId = value(params, "chapter");
|
|
84
|
+
return { view, workId, chapterId: chapterId || null };
|
|
85
|
+
}
|
|
78
86
|
if (view === "module" && workId) {
|
|
79
87
|
const module = value(params, "module");
|
|
80
88
|
return moduleSet.has(module) ? { view, workId, module } : { view: "shelf" };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type PresenceClientIdStorage = Pick<Storage, "getItem" | "removeItem" | "setItem">;
|
|
2
|
+
|
|
3
|
+
export const PRESENCE_CLIENT_ID_BEFORE_RELOGIN_KEY: string;
|
|
4
|
+
|
|
5
|
+
export function createPresenceClientId(storage?: PresenceClientIdStorage | null): string;
|
|
6
|
+
|
|
7
|
+
export function stagePresenceClientIdForRelogin(
|
|
8
|
+
storage: PresenceClientIdStorage | null | undefined,
|
|
9
|
+
clientId: string
|
|
10
|
+
): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const PRESENCE_CLIENT_ID_BEFORE_RELOGIN_KEY = "scriverse-presence-client-id-before-relogin-v1";
|
|
2
|
+
|
|
3
|
+
function generatePresenceClientId() {
|
|
4
|
+
if (typeof globalThis.crypto.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
5
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
6
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
7
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
8
|
+
const value = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
9
|
+
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createPresenceClientId(storage) {
|
|
13
|
+
try {
|
|
14
|
+
const stagedClientId = storage?.getItem(PRESENCE_CLIENT_ID_BEFORE_RELOGIN_KEY) ?? "";
|
|
15
|
+
if (stagedClientId) {
|
|
16
|
+
storage?.removeItem(PRESENCE_CLIENT_ID_BEFORE_RELOGIN_KEY);
|
|
17
|
+
return stagedClientId;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// 浏览器禁用存储时退化为当前页面的新客户端标识
|
|
21
|
+
}
|
|
22
|
+
return generatePresenceClientId();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stagePresenceClientIdForRelogin(storage, clientId) {
|
|
26
|
+
try {
|
|
27
|
+
storage?.setItem(PRESENCE_CLIENT_ID_BEFORE_RELOGIN_KEY, clientId);
|
|
28
|
+
} catch {
|
|
29
|
+
// 浏览器禁用存储时保持现有刷新行为
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type ReadingMode = "scroll" | "paged";
|
|
2
|
+
export type ReadingTheme = "paper" | "light" | "dark";
|
|
3
|
+
export type ReadingPreferences = { mode: ReadingMode; fontSize: number; lineHeight: number; theme: ReadingTheme };
|
|
4
|
+
export type ReadingChapter = {
|
|
5
|
+
id: string;
|
|
6
|
+
workId: string;
|
|
7
|
+
volumeId: string;
|
|
8
|
+
volumeTitle: string;
|
|
9
|
+
title: string;
|
|
10
|
+
chapterType: string;
|
|
11
|
+
volumeIndex: number;
|
|
12
|
+
chapterIndex: number;
|
|
13
|
+
sequenceIndex: number;
|
|
14
|
+
};
|
|
15
|
+
export type ReadingPosition = { chapterId: string; scrollRatio: number; pageIndex: number };
|
|
16
|
+
|
|
17
|
+
export const READING_PREFERENCES_STORAGE_KEY: string;
|
|
18
|
+
export const READING_POSITION_STORAGE_PREFIX: string;
|
|
19
|
+
export const DEFAULT_READING_PREFERENCES: Readonly<ReadingPreferences>;
|
|
20
|
+
export function normalizeReadingPreferences(value: unknown): ReadingPreferences;
|
|
21
|
+
export function readingPositionStorageKey(workId: unknown): string;
|
|
22
|
+
export function buildReadingChapterSequence(work: unknown): ReadingChapter[];
|
|
23
|
+
export function normalizeReadingPosition(value: unknown, sequence: ReadingChapter[]): ReadingPosition | null;
|
|
24
|
+
export function resolveReadingStart(sequence: ReadingChapter[], options?: { chapterId?: unknown; volumeId?: unknown; storedPosition?: unknown }): ReadingChapter | null;
|
|
25
|
+
export function adjacentReadingChapter(sequence: ReadingChapter[], chapterId: string, direction: number): ReadingChapter | null;
|
|
26
|
+
export function resolvePagedReadingStep(input: { sequence: ReadingChapter[]; chapterId: string; pageIndex: number; pageCount: number }, direction: number): { chapterId: string; pageIndex: number; chapterChanged: boolean } | null;
|
|
27
|
+
export function createReadingRequestGate(): {
|
|
28
|
+
begin(chapterId: string): { chapterId: string; generation: number; signal: AbortSignal };
|
|
29
|
+
isCurrent(request: { chapterId: string; generation: number; signal: AbortSignal }): boolean;
|
|
30
|
+
finish(request: { chapterId: string; generation: number }): void;
|
|
31
|
+
cancel(): void;
|
|
32
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export const READING_PREFERENCES_STORAGE_KEY = "scriverse-reading-preferences-v1";
|
|
2
|
+
export const READING_POSITION_STORAGE_PREFIX = "scriverse-reading-position-v1:";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_READING_PREFERENCES = Object.freeze({
|
|
5
|
+
mode: "scroll",
|
|
6
|
+
fontSize: 20,
|
|
7
|
+
lineHeight: 1.9,
|
|
8
|
+
theme: "paper"
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const readingModes = new Set(["scroll", "paged"]);
|
|
12
|
+
const readingFontSizes = new Set([16, 18, 20, 22, 24]);
|
|
13
|
+
const readingLineHeights = new Set([1.6, 1.8, 1.9, 2, 2.2]);
|
|
14
|
+
const readingThemes = new Set(["paper", "light", "dark"]);
|
|
15
|
+
|
|
16
|
+
function record(value) {
|
|
17
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeReadingPreferences(value) {
|
|
21
|
+
const candidate = record(value);
|
|
22
|
+
const fontSize = Number(candidate.fontSize);
|
|
23
|
+
const lineHeight = Number(candidate.lineHeight);
|
|
24
|
+
return {
|
|
25
|
+
mode: readingModes.has(candidate.mode) ? candidate.mode : DEFAULT_READING_PREFERENCES.mode,
|
|
26
|
+
fontSize: readingFontSizes.has(fontSize) ? fontSize : DEFAULT_READING_PREFERENCES.fontSize,
|
|
27
|
+
lineHeight: readingLineHeights.has(lineHeight) ? lineHeight : DEFAULT_READING_PREFERENCES.lineHeight,
|
|
28
|
+
theme: readingThemes.has(candidate.theme) ? candidate.theme : DEFAULT_READING_PREFERENCES.theme
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readingPositionStorageKey(workId) {
|
|
33
|
+
return `${READING_POSITION_STORAGE_PREFIX}${encodeURIComponent(String(workId ?? ""))}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildReadingChapterSequence(work) {
|
|
37
|
+
const workId = String(work?.id ?? "");
|
|
38
|
+
const volumes = Array.isArray(work?.volumes) ? work.volumes : [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const sequence = [];
|
|
41
|
+
for (const [volumeIndex, volume] of volumes.entries()) {
|
|
42
|
+
const volumeId = String(volume?.id ?? "");
|
|
43
|
+
const chapters = Array.isArray(volume?.chapters) ? volume.chapters : [];
|
|
44
|
+
for (const [chapterIndex, chapter] of chapters.entries()) {
|
|
45
|
+
const id = String(chapter?.id ?? "");
|
|
46
|
+
if (!id || seen.has(id)) continue;
|
|
47
|
+
seen.add(id);
|
|
48
|
+
sequence.push({
|
|
49
|
+
id,
|
|
50
|
+
workId: String(chapter?.workId ?? workId),
|
|
51
|
+
volumeId: String(chapter?.volumeId ?? volumeId),
|
|
52
|
+
volumeTitle: String(volume?.title ?? "正文"),
|
|
53
|
+
title: String(chapter?.title ?? "未命名章节"),
|
|
54
|
+
chapterType: String(chapter?.chapterType ?? "正文"),
|
|
55
|
+
volumeIndex,
|
|
56
|
+
chapterIndex,
|
|
57
|
+
sequenceIndex: sequence.length
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return sequence;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeReadingPosition(value, sequence) {
|
|
65
|
+
const candidate = record(value);
|
|
66
|
+
const chapterId = String(candidate.chapterId ?? "");
|
|
67
|
+
if (!sequence.some((chapter) => chapter.id === chapterId)) return null;
|
|
68
|
+
const scrollRatio = Number(candidate.scrollRatio);
|
|
69
|
+
const pageIndex = Number(candidate.pageIndex);
|
|
70
|
+
return {
|
|
71
|
+
chapterId,
|
|
72
|
+
scrollRatio: Number.isFinite(scrollRatio) ? Math.min(1, Math.max(0, scrollRatio)) : 0,
|
|
73
|
+
pageIndex: Number.isInteger(pageIndex) && pageIndex >= 0 ? pageIndex : 0
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function resolveReadingStart(sequence, options = {}) {
|
|
78
|
+
if (!sequence.length) return null;
|
|
79
|
+
const requestedChapterId = String(options.chapterId ?? "");
|
|
80
|
+
const requested = sequence.find((chapter) => chapter.id === requestedChapterId);
|
|
81
|
+
if (requested) return requested;
|
|
82
|
+
const stored = normalizeReadingPosition(options.storedPosition, sequence);
|
|
83
|
+
if (stored) return sequence.find((chapter) => chapter.id === stored.chapterId) ?? sequence[0];
|
|
84
|
+
const volumeId = String(options.volumeId ?? "");
|
|
85
|
+
return sequence.find((chapter) => chapter.volumeId === volumeId) ?? sequence[0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function adjacentReadingChapter(sequence, chapterId, direction) {
|
|
89
|
+
const index = sequence.findIndex((chapter) => chapter.id === chapterId);
|
|
90
|
+
if (index < 0) return null;
|
|
91
|
+
return sequence[index + (direction < 0 ? -1 : 1)] ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolvePagedReadingStep({ sequence, chapterId, pageIndex, pageCount }, direction) {
|
|
95
|
+
const safePageCount = Math.max(1, Math.floor(Number(pageCount) || 1));
|
|
96
|
+
const safePageIndex = Math.min(safePageCount - 1, Math.max(0, Math.floor(Number(pageIndex) || 0)));
|
|
97
|
+
if (direction >= 0) {
|
|
98
|
+
if (safePageIndex < safePageCount - 1) return { chapterId, pageIndex: safePageIndex + 1, chapterChanged: false };
|
|
99
|
+
const next = adjacentReadingChapter(sequence, chapterId, 1);
|
|
100
|
+
return next ? { chapterId: next.id, pageIndex: 0, chapterChanged: true } : null;
|
|
101
|
+
}
|
|
102
|
+
if (safePageIndex > 0) return { chapterId, pageIndex: safePageIndex - 1, chapterChanged: false };
|
|
103
|
+
const previous = adjacentReadingChapter(sequence, chapterId, -1);
|
|
104
|
+
return previous ? { chapterId: previous.id, pageIndex: -1, chapterChanged: true } : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createReadingRequestGate() {
|
|
108
|
+
let generation = 0;
|
|
109
|
+
let active = null;
|
|
110
|
+
return {
|
|
111
|
+
begin(chapterId) {
|
|
112
|
+
active?.controller.abort();
|
|
113
|
+
const request = {
|
|
114
|
+
chapterId: String(chapterId),
|
|
115
|
+
generation: ++generation,
|
|
116
|
+
controller: new AbortController()
|
|
117
|
+
};
|
|
118
|
+
active = request;
|
|
119
|
+
return { chapterId: request.chapterId, generation: request.generation, signal: request.controller.signal };
|
|
120
|
+
},
|
|
121
|
+
isCurrent(request) {
|
|
122
|
+
return Boolean(active)
|
|
123
|
+
&& active.chapterId === request?.chapterId
|
|
124
|
+
&& active.generation === request?.generation
|
|
125
|
+
&& !request?.signal?.aborted;
|
|
126
|
+
},
|
|
127
|
+
finish(request) {
|
|
128
|
+
if (active?.generation === request?.generation) active = null;
|
|
129
|
+
},
|
|
130
|
+
cancel() {
|
|
131
|
+
active?.controller.abort();
|
|
132
|
+
active = null;
|
|
133
|
+
generation += 1;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -15,3 +15,13 @@ export function collectS3BackupRunTransitions<T extends S3BackupRunSnapshot>(
|
|
|
15
15
|
maximumSnapshots?: number
|
|
16
16
|
): { snapshots: Map<string, string>; failures: T[] };
|
|
17
17
|
export function s3BackupFailureToast(run: Pick<S3BackupRunSnapshot, "targetName" | "errorMessage">): string;
|
|
18
|
+
export function s3BackupEncryptionPresentation(state?: {
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
keyConfiguredAt?: string | null;
|
|
21
|
+
} | null): {
|
|
22
|
+
label: string;
|
|
23
|
+
statusClass: string;
|
|
24
|
+
description: string;
|
|
25
|
+
showPrivateBucketWarning: boolean;
|
|
26
|
+
};
|
|
27
|
+
export function s3BackupEncryptionKeyFile(key: unknown): string;
|
|
@@ -26,3 +26,32 @@ export function s3BackupFailureToast(run) {
|
|
|
26
26
|
const reason = String(run?.errorMessage || "S3 服务请求失败").trim();
|
|
27
27
|
return `S3 备份目标“${run?.targetName || "未命名目标"}”失败:${reason}`;
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
export function s3BackupEncryptionPresentation(state) {
|
|
31
|
+
if (state?.enabled) {
|
|
32
|
+
return {
|
|
33
|
+
label: "已开启",
|
|
34
|
+
statusClass: "is-enabled",
|
|
35
|
+
description: "新生成的数据库快照、恢复密钥和图片会以 AES-256-GCM 信封密文上传。",
|
|
36
|
+
showPrivateBucketWarning: false
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (state?.keyConfiguredAt) {
|
|
40
|
+
return {
|
|
41
|
+
label: "已关闭",
|
|
42
|
+
statusClass: "",
|
|
43
|
+
description: "新备份恢复为明文上传;原密钥仍保留,以便解密历史备份。",
|
|
44
|
+
showPrivateBucketWarning: true
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
label: "未开启",
|
|
49
|
+
statusClass: "",
|
|
50
|
+
description: "备份将以明文上传,请确保所有 S3 目标桶均为私有桶。",
|
|
51
|
+
showPrivateBucketWarning: true
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function s3BackupEncryptionKeyFile(key) {
|
|
56
|
+
return `${String(key).trim()}\n`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type SettingLockFilter = "all" | "locked" | "unlocked";
|
|
2
|
+
|
|
3
|
+
export interface SettingFilterOptions {
|
|
4
|
+
keyword?: string;
|
|
5
|
+
category?: string;
|
|
6
|
+
lockState?: SettingLockFilter;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SettingFilterRecord {
|
|
10
|
+
title?: unknown;
|
|
11
|
+
category?: unknown;
|
|
12
|
+
contentPreview?: unknown;
|
|
13
|
+
locked?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function filterSettings<T extends SettingFilterRecord>(records: readonly T[], filters?: SettingFilterOptions): T[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
function normalizedText(value) {
|
|
2
|
+
return String(value ?? "").toLocaleLowerCase("zh-CN");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function filterSettings(records, { keyword = "", category = "", lockState = "all" } = {}) {
|
|
6
|
+
const normalizedKeyword = normalizedText(keyword).trim();
|
|
7
|
+
const normalizedCategory = String(category ?? "").trim();
|
|
8
|
+
const normalizedLockState = lockState === "locked" || lockState === "unlocked" ? lockState : "all";
|
|
9
|
+
|
|
10
|
+
return records.filter((record) => {
|
|
11
|
+
const matchesKeyword = !normalizedKeyword
|
|
12
|
+
|| normalizedText(record?.title).includes(normalizedKeyword)
|
|
13
|
+
|| normalizedText(record?.contentPreview).includes(normalizedKeyword);
|
|
14
|
+
const matchesCategory = !normalizedCategory || String(record?.category ?? "") === normalizedCategory;
|
|
15
|
+
const matchesLockState = normalizedLockState === "all"
|
|
16
|
+
|| (normalizedLockState === "locked" ? Boolean(record?.locked) : !record?.locked);
|
|
17
|
+
return matchesKeyword && matchesCategory && matchesLockState;
|
|
18
|
+
});
|
|
19
|
+
}
|