@musnows/scriverse 1.1.1 → 1.1.3

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.
@@ -0,0 +1,196 @@
1
+ export class MobileOfflineApiError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.name = "MobileOfflineApiError";
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ function page(items, url) {
10
+ const requestedPage = Math.max(1, Number(url.searchParams.get("page")) || 1);
11
+ const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit")) || 30));
12
+ const start = (requestedPage - 1) * limit;
13
+ const selected = items.slice(start, start + limit);
14
+ return {
15
+ items: selected,
16
+ page: requestedPage,
17
+ limit,
18
+ total: items.length,
19
+ hasMore: start + selected.length < items.length,
20
+ nextPage: start + selected.length < items.length ? requestedPage + 1 : null
21
+ };
22
+ }
23
+
24
+ function sortDirectory(items) {
25
+ return [...items].sort((left, right) => (
26
+ Number(left.sortOrder ?? 0) - Number(right.sortOrder ?? 0)
27
+ || String(left.title ?? left.name ?? left.id).localeCompare(String(right.title ?? right.name ?? right.id), "zh-CN")
28
+ ));
29
+ }
30
+
31
+ function snapshotRecord(entity) {
32
+ const snapshot = structuredClone(entity.snapshot);
33
+ return {
34
+ ...snapshot,
35
+ ...(typeof snapshot?.content === "string" ? { wordCount: textCount(snapshot.content) } : {}),
36
+ versionNo: Number(entity.snapshot?.versionNo ?? entity.serverVersionNo),
37
+ mobileLocalRevisionNo: Number(entity.localRevisionNo ?? 0),
38
+ mobileSyncConflict: entity.conflict === true,
39
+ mobileReadOnly: entity.locked === true
40
+ };
41
+ }
42
+
43
+ function workAccess(work) {
44
+ const saved = work.permissionsSnapshot && typeof work.permissionsSnapshot === "object" && !Array.isArray(work.permissionsSnapshot)
45
+ ? work.permissionsSnapshot
46
+ : {};
47
+ const modulePermissions = saved.modulePermissions && typeof saved.modulePermissions === "object" && !Array.isArray(saved.modulePermissions)
48
+ ? saved.modulePermissions
49
+ : saved;
50
+ const permissionValues = Object.values(modulePermissions);
51
+ const fallbackRole = permissionValues.length > 0 && permissionValues.every((access) => access === "write")
52
+ ? "editor"
53
+ : permissionValues.some((access) => access === "write") ? "custom" : "viewer";
54
+ return {
55
+ accessRole: typeof saved.accessRole === "string" ? saved.accessRole : fallbackRole,
56
+ modulePermissions: structuredClone(modulePermissions)
57
+ };
58
+ }
59
+
60
+ function textCount(value) {
61
+ return Array.from(String(value ?? "").replace(/\s/gu, "")).length;
62
+ }
63
+
64
+ export class MobileOfflineApi {
65
+ constructor({ store, client }) {
66
+ this.store = store;
67
+ this.client = client;
68
+ }
69
+
70
+ async work(workId, { includeVolumes = false } = {}) {
71
+ const cached = await this.store.getWork(workId);
72
+ if (!cached) throw new MobileOfflineApiError("SYNC_WORK_NOT_FOUND", "离线副本中不存在该作品");
73
+ const volumeEntities = sortDirectory((await this.store.listEntities(workId, "volume")).map(snapshotRecord));
74
+ const chapterEntities = await this.store.listEntities(workId, "chapter");
75
+ const chaptersByVolume = new Map();
76
+ for (const entity of chapterEntities) {
77
+ const chapter = snapshotRecord(entity);
78
+ const volumeId = String(chapter.volumeId ?? "");
79
+ const records = chaptersByVolume.get(volumeId) ?? [];
80
+ records.push(chapter);
81
+ chaptersByVolume.set(volumeId, records);
82
+ }
83
+ const volumes = volumeEntities.map((volume) => {
84
+ const chapters = sortDirectory(chaptersByVolume.get(String(volume.id)) ?? []);
85
+ return { ...volume, chapterCount: chapters.length, chapters: includeVolumes ? chapters : [] };
86
+ });
87
+ const summary = structuredClone(cached.summary ?? {});
88
+ return {
89
+ ...summary,
90
+ id: String(summary.id ?? cached.workId),
91
+ title: String(summary.title ?? cached.title ?? "未命名作品"),
92
+ coverUrl: typeof summary.coverUrl === "string" ? summary.coverUrl : null,
93
+ ...workAccess(cached),
94
+ offlineAccessEnabled: true,
95
+ wordCount: chapterEntities.reduce((total, entity) => total + textCount(entity.snapshot?.content), 0),
96
+ chapterCount: chapterEntities.length,
97
+ volumes
98
+ };
99
+ }
100
+
101
+ async works(url) {
102
+ const cached = await this.store.listWorks();
103
+ const works = [];
104
+ for (const work of cached) works.push(await this.work(work.workId));
105
+ return page(works, url);
106
+ }
107
+
108
+ async volumeChapters(volumeId, url) {
109
+ const works = await this.store.listWorks();
110
+ for (const work of works) {
111
+ const chapters = sortDirectory((await this.store.listEntities(work.workId, "chapter")).map(snapshotRecord))
112
+ .filter((chapter) => String(chapter.volumeId) === volumeId);
113
+ if (chapters.length > 0 || (await this.store.listEntities(work.workId, "volume")).some((entity) => String(entity.entityId) === volumeId)) {
114
+ return page(chapters, url);
115
+ }
116
+ }
117
+ throw new MobileOfflineApiError("SYNC_VOLUME_NOT_FOUND", "离线副本中不存在该分卷");
118
+ }
119
+
120
+ async entity(entityType, entityId) {
121
+ for (const work of await this.store.listWorks()) {
122
+ const entity = await this.store.getEntity(work.workId, entityType, entityId);
123
+ if (entity) return { workId: work.workId, entity, snapshot: snapshotRecord(entity) };
124
+ }
125
+ throw new MobileOfflineApiError("SYNC_ENTITY_NOT_FOUND", "离线副本中不存在该记录");
126
+ }
127
+
128
+ async settings(workId, url, contextOnly = false) {
129
+ const records = sortDirectory(await this.store.listEntities(workId, "setting")).map(snapshotRecord);
130
+ if (contextOnly) return records.filter((setting) => setting.locked === true);
131
+ return page(records, url);
132
+ }
133
+
134
+ async saveEntity(entityType, entityId, body) {
135
+ const current = await this.entity(entityType, entityId);
136
+ if (current.entity.locked || current.entity.conflict) {
137
+ throw new MobileOfflineApiError("SYNC_ENTITY_READ_ONLY", "该记录存在冲突或已锁定为只读,请先在同步中心处理");
138
+ }
139
+ const allowed = entityType === "chapter"
140
+ ? ["title", "content", "chapterType"]
141
+ : ["title", "category", "content", "tags", "status", "locked", "evidence", "scope", "authorNote"];
142
+ const snapshot = { ...current.snapshot };
143
+ for (const key of allowed) {
144
+ if (body && Object.hasOwn(body, key)) snapshot[key] = structuredClone(body[key]);
145
+ }
146
+ const saved = await this.store.saveLocalEntity(current.workId, entityType, entityId, snapshot);
147
+ await this.client?.emitStatus("saved", current.workId);
148
+ return {
149
+ ...snapshot,
150
+ versionNo: Number(current.entity.serverVersionNo),
151
+ mobileLocalRevisionNo: Number(saved.localRevisionNo),
152
+ updatedAt: saved.savedAt
153
+ };
154
+ }
155
+
156
+ unsupported() {
157
+ throw new MobileOfflineApiError(
158
+ "MOBILE_OFFLINE_OPERATION_UNSUPPORTED",
159
+ "当前离线副本仅支持修改已下载的正文和设定;其他操作需恢复连接后完成"
160
+ );
161
+ }
162
+
163
+ async request(path, options = {}) {
164
+ const method = String(options.method ?? "GET").toUpperCase();
165
+ const url = new URL(path, globalThis.location?.origin ?? "https://mobile.invalid");
166
+ const pathname = url.pathname;
167
+ if (method === "GET" && pathname === "/api/works") return this.works(url);
168
+ const workMatch = pathname.match(/^\/api\/works\/([^/]+)$/u);
169
+ if (method === "GET" && workMatch) return this.work(decodeURIComponent(workMatch[1]), { includeVolumes: url.searchParams.get("directory") !== "volumes" });
170
+ const volumeChaptersMatch = pathname.match(/^\/api\/volumes\/([^/]+)\/chapters$/u);
171
+ if (method === "GET" && volumeChaptersMatch) return this.volumeChapters(decodeURIComponent(volumeChaptersMatch[1]), url);
172
+ const chapterMatch = pathname.match(/^\/api\/chapters\/([^/]+)$/u);
173
+ if (chapterMatch && method === "GET") return (await this.entity("chapter", decodeURIComponent(chapterMatch[1]))).snapshot;
174
+ if (chapterMatch && method === "PATCH") return this.saveEntity("chapter", decodeURIComponent(chapterMatch[1]), options.body);
175
+ const settingMatch = pathname.match(/^\/api\/settings\/([^/]+)$/u);
176
+ if (settingMatch && method === "GET") return (await this.entity("setting", decodeURIComponent(settingMatch[1]))).snapshot;
177
+ if (settingMatch && method === "PATCH") return this.saveEntity("setting", decodeURIComponent(settingMatch[1]), options.body);
178
+ const workSettingsMatch = pathname.match(/^\/api\/works\/([^/]+)\/settings(?:\/context)?$/u);
179
+ if (method === "GET" && workSettingsMatch) {
180
+ return this.settings(
181
+ decodeURIComponent(workSettingsMatch[1]),
182
+ url,
183
+ pathname.endsWith("/context")
184
+ );
185
+ }
186
+ if (method === "GET" && /^\/api\/chapters\/[^/]+\/(?:annotation-counts|annotations)$/u.test(pathname)) return [];
187
+ if (method === "GET" && /^\/api\/works\/[^/]+\/chapters\/[^/]+\/foreshadow-reminders$/u.test(pathname)) return [];
188
+ if (method === "GET" && /^\/api\/(?:chapters\/[^/]+\/versions|entity-versions\/[^/]+\/[^/]+)$/u.test(pathname)) return [];
189
+ if (method === "POST" && /^\/api\/works\/[^/]+\/presence$/u.test(pathname)) return { participants: [], recentChanges: [] };
190
+ return this.unsupported();
191
+ }
192
+ }
193
+
194
+ export function createMobileOfflineApi(options) {
195
+ return new MobileOfflineApi(options);
196
+ }
@@ -0,0 +1,82 @@
1
+ const CACHE_NAME = "scriverse-mobile-shell-v2";
2
+ const STATIC_REQUEST = /\.(?:css|js|mjs|svg|png|webp|woff2?|ttf)(?:\?.*)?$/iu;
3
+ const MOBILE_SHELL_URL = new URL("/?scriverseMobile=1", self.location.origin);
4
+
5
+ async function cacheMobileShell() {
6
+ const response = await fetch(MOBILE_SHELL_URL, { cache: "reload" });
7
+ if (!response.ok || response.type === "opaque") return;
8
+ const cache = await caches.open(CACHE_NAME);
9
+ await cache.put(new Request(MOBILE_SHELL_URL), response.clone());
10
+ await cache.put(new Request(new URL("/index.html", self.location.origin)), response);
11
+ }
12
+
13
+ self.addEventListener("install", (event) => {
14
+ event.waitUntil((async () => {
15
+ await self.skipWaiting();
16
+ await cacheMobileShell().catch(() => undefined);
17
+ })());
18
+ });
19
+
20
+ self.addEventListener("activate", (event) => {
21
+ event.waitUntil(self.clients.claim());
22
+ });
23
+
24
+ self.addEventListener("message", (event) => {
25
+ if (event.data?.type !== "CACHE_ASSETS" || !Array.isArray(event.data.urls)) return;
26
+ const urls = [...new Set(event.data.urls)].filter((value) => {
27
+ if (typeof value !== "string") return false;
28
+ try {
29
+ const url = new URL(value, self.location.origin);
30
+ return url.origin === self.location.origin
31
+ && !url.pathname.startsWith("/api/")
32
+ && STATIC_REQUEST.test(url.pathname + url.search);
33
+ } catch {
34
+ return false;
35
+ }
36
+ }).slice(0, 500);
37
+ event.waitUntil((async () => {
38
+ const cache = await caches.open(CACHE_NAME);
39
+ await Promise.all(urls.map(async (value) => {
40
+ try {
41
+ const request = new Request(new URL(value, self.location.origin), {
42
+ cache: "force-cache",
43
+ credentials: "same-origin"
44
+ });
45
+ const response = await fetch(request);
46
+ if (response.ok && response.type !== "opaque") await cache.put(request, response);
47
+ } catch {
48
+ // 单个资源缓存失败时继续处理其余资源。
49
+ }
50
+ }));
51
+ })());
52
+ });
53
+
54
+ async function cacheShellResponse(request, response) {
55
+ if (!response || !response.ok || response.type === "opaque") return response;
56
+ const cache = await caches.open(CACHE_NAME);
57
+ await cache.put(request, response.clone());
58
+ return response;
59
+ }
60
+
61
+ self.addEventListener("fetch", (event) => {
62
+ const request = event.request;
63
+ if (request.method !== "GET" || new URL(request.url).origin !== self.location.origin) return;
64
+ const url = new URL(request.url);
65
+ if (url.pathname.startsWith("/api/")) return;
66
+ if (request.mode === "navigate") {
67
+ event.respondWith((async () => {
68
+ try {
69
+ return await cacheShellResponse(request, await fetch(request));
70
+ } catch {
71
+ return await caches.match(request) ?? await caches.match("/index.html") ?? Response.error();
72
+ }
73
+ })());
74
+ return;
75
+ }
76
+ if (!STATIC_REQUEST.test(url.pathname + url.search)) return;
77
+ event.respondWith((async () => {
78
+ const cached = await caches.match(request);
79
+ const network = fetch(request).then((response) => cacheShellResponse(request, response)).catch(() => null);
80
+ return cached ?? await network ?? Response.error();
81
+ })());
82
+ });
@@ -0,0 +1,292 @@
1
+ const SYNC_PROTOCOL = 1;
2
+ const SYNC_POLL_INTERVAL_MS = 30_000;
3
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
4
+ const READ_ONLY_ERROR_CODES = new Set([
5
+ "OFFLINE_ACCESS_DISABLED",
6
+ "WORK_ACCESS_DENIED",
7
+ "WORK_EDIT_DENIED",
8
+ "WORK_MODULE_READ_DENIED",
9
+ "WORK_MODULE_WRITE_DENIED",
10
+ "WORK_OWNER_REQUIRED"
11
+ ]);
12
+
13
+ export class MobileSyncClientError extends Error {
14
+ constructor(code, message, options = {}) {
15
+ super(message);
16
+ this.name = "MobileSyncClientError";
17
+ this.code = code;
18
+ this.status = options.status ?? 0;
19
+ this.retryable = options.retryable === true;
20
+ this.retryAfterSeconds = options.retryAfterSeconds ?? null;
21
+ }
22
+ }
23
+
24
+ function responseError(payload, response) {
25
+ const code = typeof payload?.error?.code === "string" ? payload.error.code : `SYNC_HTTP_${response.status}`;
26
+ const message = typeof payload?.error?.message === "string" ? payload.error.message : "同步请求失败,请稍后重试";
27
+ const retryAfter = Number(response.headers.get("retry-after"));
28
+ return new MobileSyncClientError(code, message, {
29
+ status: response.status,
30
+ retryable: RETRYABLE_STATUS.has(response.status),
31
+ retryAfterSeconds: Number.isInteger(retryAfter) && retryAfter > 0 ? retryAfter : null
32
+ });
33
+ }
34
+
35
+ async function jsonResponse(response) {
36
+ let payload = null;
37
+ try {
38
+ payload = await response.json();
39
+ } catch {
40
+ if (response.ok) throw new MobileSyncClientError("SYNC_RESPONSE_INVALID", "Server 返回的内容无法识别");
41
+ }
42
+ if (!response.ok) throw responseError(payload, response);
43
+ if (!payload || typeof payload !== "object" || !("data" in payload)) {
44
+ throw new MobileSyncClientError("SYNC_RESPONSE_INVALID", "Server 返回的内容无法识别");
45
+ }
46
+ return payload.data;
47
+ }
48
+
49
+ function retryDelay(attempts, retryAfterSeconds = null) {
50
+ if (retryAfterSeconds) return retryAfterSeconds * 1_000;
51
+ return Math.min(5 * 60_000, 1_000 * (2 ** Math.max(0, Math.min(8, attempts - 1))));
52
+ }
53
+
54
+ function rangesOverlap(range, version) {
55
+ return range && Number(range.min) <= version && Number(range.max) >= version;
56
+ }
57
+
58
+ export function syncMutationSnapshot(entityType, snapshot) {
59
+ if (entityType === "chapter") {
60
+ return {
61
+ title: String(snapshot?.title ?? ""),
62
+ content: String(snapshot?.content ?? ""),
63
+ chapterType: ["正文", "设定", "作者的话", "其他"].includes(snapshot?.chapterType) ? snapshot.chapterType : "正文"
64
+ };
65
+ }
66
+ if (entityType === "setting") {
67
+ return {
68
+ title: String(snapshot?.title ?? ""),
69
+ category: String(snapshot?.category ?? ""),
70
+ content: String(snapshot?.content ?? ""),
71
+ tags: Array.isArray(snapshot?.tags) ? snapshot.tags : [],
72
+ status: ["draft", "pending", "confirmed", "deprecated"].includes(snapshot?.status) ? snapshot.status : "draft",
73
+ locked: snapshot?.locked === true,
74
+ evidence: Array.isArray(snapshot?.evidence) ? snapshot.evidence : [],
75
+ scope: snapshot?.scope && typeof snapshot.scope === "object" && !Array.isArray(snapshot.scope) ? snapshot.scope : {},
76
+ authorNote: String(snapshot?.authorNote ?? "")
77
+ };
78
+ }
79
+ throw new MobileSyncClientError("SYNC_ENTITY_TYPE_UNSUPPORTED", "该类型暂不支持离线同步");
80
+ }
81
+
82
+ export class MobileSyncClient {
83
+ constructor({
84
+ profile = null,
85
+ user,
86
+ store,
87
+ csrfToken = null,
88
+ fetchImpl = globalThis.fetch,
89
+ online = () => globalThis.navigator?.onLine !== false,
90
+ pollIntervalMs = SYNC_POLL_INTERVAL_MS
91
+ }) {
92
+ this.profile = profile;
93
+ this.user = user;
94
+ this.store = store;
95
+ this.getCsrfToken = typeof csrfToken === "function" ? csrfToken : () => csrfToken;
96
+ this.fetch = (input, init) => fetchImpl(input, init);
97
+ this.online = online;
98
+ this.syncing = new Set();
99
+ this.disposed = false;
100
+ this.handleOnline = () => { void this.syncAll(); };
101
+ globalThis.addEventListener?.("online", this.handleOnline);
102
+ this.pollTimer = Number(pollIntervalMs) > 0
103
+ ? globalThis.setInterval?.(() => { void this.syncAll(); }, Number(pollIntervalMs)) ?? null
104
+ : null;
105
+ }
106
+
107
+ async request(path, options = {}) {
108
+ const method = String(options.method ?? "GET").toUpperCase();
109
+ let response;
110
+ try {
111
+ response = await this.fetch(path, {
112
+ method,
113
+ headers: {
114
+ Accept: "application/json",
115
+ ...(method === "GET" || method === "HEAD" || method === "OPTIONS" || !this.getCsrfToken()
116
+ ? {}
117
+ : { "X-CSRF-Token": this.getCsrfToken() }),
118
+ ...(options.body === undefined ? {} : { "Content-Type": "application/json" })
119
+ },
120
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
121
+ redirect: "error",
122
+ cache: "no-store"
123
+ });
124
+ } catch (error) {
125
+ throw new MobileSyncClientError("SYNC_NETWORK_ERROR", "无法连接 Server,本地变更已保留", { retryable: true, cause: error });
126
+ }
127
+ return jsonResponse(response);
128
+ }
129
+
130
+ async downloadWork(work, { scheduleRequest = null } = {}) {
131
+ if (!work?.id) throw new MobileSyncClientError("SYNC_WORK_INVALID", "请先选择要下载的作品");
132
+ if (work.offlineAccessEnabled !== true) {
133
+ throw new MobileSyncClientError("OFFLINE_ACCESS_DISABLED", "作品所有者尚未允许 手机端离线访问");
134
+ }
135
+ const request = (path, options = {}) => typeof scheduleRequest === "function"
136
+ ? scheduleRequest(() => this.request(path, options))
137
+ : this.request(path, options);
138
+ const descriptor = await request(`/api/sync/works/${encodeURIComponent(work.id)}/snapshots`, { method: "POST", body: {} });
139
+ const items = [];
140
+ let after = 0;
141
+ try {
142
+ while (true) {
143
+ const page = await request(`/api/sync/snapshots/${encodeURIComponent(descriptor.snapshotId)}/items?after=${after}&limit=100`);
144
+ items.push(...page.items);
145
+ if (!page.hasMore) break;
146
+ if (!Number.isInteger(page.nextAfter) || page.nextAfter <= after) {
147
+ throw new MobileSyncClientError("SYNC_SNAPSHOT_INVALID", "Server 返回的离线数据不完整");
148
+ }
149
+ after = page.nextAfter;
150
+ }
151
+ const stored = await this.store.replaceSnapshot({
152
+ workId: work.id,
153
+ cutoffCursor: descriptor.cutoffCursor,
154
+ items,
155
+ permissionsSnapshot: {
156
+ accessRole: work.accessRole ?? "viewer",
157
+ modulePermissions: work.modulePermissions ?? null
158
+ }
159
+ });
160
+ await this.emitStatus("downloaded", work.id);
161
+ return stored;
162
+ } finally {
163
+ await request(`/api/sync/snapshots/${encodeURIComponent(descriptor.snapshotId)}`, { method: "DELETE" }).catch(() => undefined);
164
+ }
165
+ }
166
+
167
+ async syncAll() {
168
+ if (this.disposed || !this.online()) return [];
169
+ const works = await this.store.listWorks();
170
+ const results = [];
171
+ for (const work of works) results.push(await this.syncWork(work.workId).catch((error) => ({ status: "failed", error })));
172
+ return results;
173
+ }
174
+
175
+ async syncWork(workId) {
176
+ if (this.disposed) return { status: "disposed" };
177
+ if (!this.online()) {
178
+ await this.store.setWorkStatus(workId, "offline");
179
+ await this.emitStatus("offline", workId);
180
+ return { status: "offline" };
181
+ }
182
+ if (this.syncing.has(workId)) return { status: "already-syncing" };
183
+ const lease = await this.store.acquireSyncLease(workId);
184
+ if (!lease) return { status: "leased-elsewhere" };
185
+ this.syncing.add(workId);
186
+ await this.store.setWorkStatus(workId, "syncing");
187
+ await this.emitStatus("syncing", workId);
188
+ try {
189
+ const session = await this.request("/api/auth/session");
190
+ if (session.authenticated !== true || session.user?.userId !== this.user.userId) {
191
+ throw new MobileSyncClientError("REMOTE_LOGIN_REQUIRED", "手机端登录已失效,同步队列已暂停");
192
+ }
193
+ const health = await this.request("/api/health");
194
+ if (!rangesOverlap(health.syncProtocol, SYNC_PROTOCOL)) {
195
+ throw new MobileSyncClientError("SYNC_PROTOCOL_INCOMPATIBLE", "当前 Server 版本不支持离线同步,请升级后重试");
196
+ }
197
+ const work = await this.request(`/api/works/${encodeURIComponent(workId)}`);
198
+ if (work.offlineAccessEnabled !== true) throw new MobileSyncClientError("OFFLINE_ACCESS_DISABLED", "作品已关闭离线访问");
199
+ await this.pullWork(workId);
200
+ await this.pushWork(workId);
201
+ const summary = await this.store.statusSummary(workId);
202
+ const finalStatus = summary.rejected > 0 ? "read-only" : summary.conflicts > 0 ? "conflict" : "ready";
203
+ await this.store.setWorkStatus(workId, finalStatus === "conflict" ? "ready" : finalStatus);
204
+ await this.emitStatus(finalStatus, workId);
205
+ return { status: finalStatus, summary };
206
+ } catch (error) {
207
+ const code = error?.code ?? "SYNC_FAILED";
208
+ if (READ_ONLY_ERROR_CODES.has(code)) await this.store.setWorkStatus(workId, "read-only", code);
209
+ else if (code === "REMOTE_LOGIN_REQUIRED" || code === "SYNC_NETWORK_ERROR") await this.store.setWorkStatus(workId, "offline", code);
210
+ else await this.store.setWorkStatus(workId, "error", code);
211
+ await this.emitStatus(READ_ONLY_ERROR_CODES.has(code) ? "read-only" : "failed", workId, error);
212
+ throw error;
213
+ } finally {
214
+ this.syncing.delete(workId);
215
+ await this.store.releaseSyncLease(workId, lease.leaseId);
216
+ }
217
+ }
218
+
219
+ async pullWork(workId) {
220
+ const work = await this.store.getWork(workId);
221
+ if (!work) throw new MobileSyncClientError("SYNC_WORK_NOT_FOUND", "离线作品不存在");
222
+ let cursor = Number(work.cursor);
223
+ while (true) {
224
+ const page = await this.request(`/api/sync/works/${encodeURIComponent(workId)}/changes?after=${cursor}&limit=200`);
225
+ await this.store.applyRemoteChanges(workId, page.items, Number(page.nextCursor));
226
+ cursor = Number(page.nextCursor);
227
+ if (!page.hasMore) return cursor;
228
+ }
229
+ }
230
+
231
+ async pushWork(workId) {
232
+ const results = [];
233
+ while (true) {
234
+ const batch = await this.store.preparePushBatch(workId, 20);
235
+ if (batch.mutations.length === 0) return results;
236
+ const body = {
237
+ clientId: batch.clientId,
238
+ mutations: batch.mutations.map((mutation) => ({
239
+ ...mutation,
240
+ localSnapshot: syncMutationSnapshot(mutation.entityType, mutation.localSnapshot)
241
+ }))
242
+ };
243
+ try {
244
+ const pushed = await this.request(`/api/sync/works/${encodeURIComponent(workId)}/push`, { method: "POST", body });
245
+ await this.store.applyPushResults(workId, pushed.results);
246
+ results.push(...pushed.results);
247
+ } catch (error) {
248
+ const attempts = Math.max(...batch.mutations.map((mutation) => Number(mutation.attempts ?? 1)), 1);
249
+ if (error?.retryable) {
250
+ const delay = retryDelay(attempts, error.retryAfterSeconds);
251
+ await this.store.returnSyncingToPending(batch.mutationIds, new Date(Date.now() + delay).toISOString());
252
+ } else {
253
+ await this.store.returnSyncingToPending(batch.mutationIds, null);
254
+ }
255
+ throw error;
256
+ }
257
+ }
258
+ }
259
+
260
+ async emitStatus(status, workId, error = null) {
261
+ const works = await this.store.listWorks();
262
+ const summaries = await Promise.all(works.map((work) => this.store.statusSummary(work.workId)));
263
+ const aggregate = summaries.reduce((total, summary) => ({
264
+ pendingMutations: total.pendingMutations + summary.pending + summary.syncing,
265
+ conflicts: total.conflicts + summary.conflicts,
266
+ rejected: total.rejected + summary.rejected
267
+ }), { pendingMutations: 0, conflicts: 0, rejected: 0 });
268
+ const detail = { status, workId, ...aggregate, errorCode: error?.code ?? null };
269
+ globalThis.dispatchEvent?.(new CustomEvent("scriverse-mobile-sync-state", { detail }));
270
+ return detail;
271
+ }
272
+
273
+ dispose() {
274
+ this.disposed = true;
275
+ globalThis.removeEventListener?.("online", this.handleOnline);
276
+ if (this.pollTimer !== null) globalThis.clearInterval?.(this.pollTimer);
277
+ this.store.close();
278
+ }
279
+ }
280
+
281
+ export async function createMobileSyncClient({
282
+ user,
283
+ profile = null,
284
+ store,
285
+ csrfToken = null,
286
+ fetchImpl = globalThis.fetch
287
+ } = {}) {
288
+ if (!user?.userId || !store) return null;
289
+ await store.open();
290
+ await store.recoverSyncing();
291
+ return new MobileSyncClient({ profile, user, store, csrfToken, fetchImpl });
292
+ }