From 40c01f3c5b27aa43d2aade6362f3203d564612e6 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Wed, 19 Aug 2026 10:57:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(mobile):=20=E5=B0=8E=E5=85=A5?= =?UTF-8?q?=E6=9C=AC=E6=A9=9F=E8=B3=87=E6=96=99=E5=B1=A4=20Service=20?= =?UTF-8?q?=E5=88=86=E5=B1=A4=EF=BC=8C=E6=8B=86=E5=88=86=20lib/library.ts?= =?UTF-8?q?=20=E9=9B=99=E9=87=8D=E8=A7=92=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 比照 pwa-next/renderer 現有的 { local: {...} } 本機版 service 形狀,把 lib/library.ts 拆成 services/ 下 bookService/progressService/bookmarkService/annotationService/ settingsService 五個檔案,removeBook 的跨 store 清理職責搬到呼叫端(app/(tabs)/index.tsx) 以避免單一 service 跨界戳其他 store 的 AsyncStorage key。 Co-Authored-By: Claude Sonnet 5 --- CLIENT_SERVICE_HOOK_REFACTOR.md | 47 +++++++- mobile/app/(tabs)/index.tsx | 37 +++--- mobile/components/BookCard.tsx | 4 +- mobile/components/ListPanel.tsx | 6 +- mobile/hooks/reader/useAnnotations.ts | 11 +- mobile/hooks/reader/useBookmarks.ts | 7 +- mobile/hooks/reader/useReaderEngine.ts | 36 +++--- mobile/lib/reader/calculations.ts | 3 +- mobile/services/annotationService.ts | 30 +++++ .../library.ts => services/bookService.ts} | 108 +++++------------- mobile/services/bookmarkService.ts | 27 +++++ mobile/services/progressService.ts | 12 ++ mobile/services/settingsService.ts | 29 +++++ 13 files changed, 220 insertions(+), 137 deletions(-) create mode 100644 mobile/services/annotationService.ts rename mobile/{lib/library.ts => services/bookService.ts} (71%) create mode 100644 mobile/services/bookmarkService.ts create mode 100644 mobile/services/progressService.ts create mode 100644 mobile/services/settingsService.ts diff --git a/CLIENT_SERVICE_HOOK_REFACTOR.md b/CLIENT_SERVICE_HOOK_REFACTOR.md index 15a25c8..457ec96 100644 --- a/CLIENT_SERVICE_HOOK_REFACTOR.md +++ b/CLIENT_SERVICE_HOOK_REFACTOR.md @@ -10,7 +10,7 @@ tags: > 之後任何一次對話(不論是不是同一個 Claude session)都能從這裡接續,不用重新討論一次。 > 格式沿用 [CLOUD_SYNC_PROGRESS.md](CLOUD_SYNC_PROGRESS.md) / [FP_REFACTOR_PROGRESS.md](FP_REFACTOR_PROGRESS.md) 的既有慣例。 -**現況(2026-08-18):跨裝置雲端同步功能已全數移除(使用者決定放棄,原因是 Phase 1 卡住的 +**現況(2026-08-19):跨裝置雲端同步功能已全數移除(使用者決定放棄,原因是 Phase 1 卡住的 跨裝置同步 bug 一直沒查出根因,見下方「已放棄的雲端同步」)。pwa-next 現在是純本機儲存, 沒有登入機制、沒有後端 API route、沒有資料庫。三個子專案(`pwa-next`/`renderer`/`mobile`) 接下來只做「本機儲存版」的 Client/Service/Hook 分層,不含任何雲端/帳號功能。renderer 的本機 @@ -18,7 +18,8 @@ tags: **這是使用者明確要求「現在就推,之後有問題再補修法」下的例外**,過程中發現並修正的翻頁 進度存檔問題(`relocated` 事件雜訊覆蓋真正進度)使用者確認「好很多,只差一頁」但**尚未 確認完全解決**,還在持續追蹤,`constants/debug.ts` 的 `DEBUG_ANNOTATIONS`/`DEBUG_PROGRESS` -目前刻意保持開啟以便繼續除錯;mobile 完全還沒開始動工。 +目前刻意保持開啟以便繼續除錯;mobile 的本機資料層分層已實作完成(`yarn tsc --noEmit` +通過),使用者已在裝置/模擬器實測確認 OK,已 commit+push 到 `dev`(見下方 Phase 進度)。 --- @@ -139,9 +140,39 @@ pwa-next 的 `addBook` 內容雜湊去重 id 策略、`annotationService` 的 `u 有問題再補修法」——不是依照專案「使用者驗證過才能 commit」常規流程走的,之後接手的對話 需要知道這個殘留的一頁落差問題還開著。** -### mobile — 本機資料層分層(未開始) -拆 `mobile/lib/library.ts` 目前身兼「儲存 client」跟「資料模型定義」的雙重角色,比照 pwa-next -`bookService.ts` 現在的本機版形狀(`{ local: {...} }`)拆開。 +### mobile — 本機資料層分層(已完成並已 commit+push 到 `dev`,2026-08-19) +拆 `mobile/lib/library.ts`(原本身兼「儲存 client」跟「資料模型定義」雙重角色,283 行)為 +`mobile/services/` 下五個 `{ local: {...} }` 形狀的 service,比照 pwa-next 現有 service 的 +拆法(每個 concern 一個檔案,不是只拆一個 bookService): +- `bookService.ts`:`BookRecord` 型別+書本 metadata/epub 檔案/封面圖的本機 CRUD + (`listBooks`/`addBook`/`seedDefaultBook`/`getBookFileUri`/`getBookBase64`/ + `removeBook`/`updateBookMeta`/`saveCoverImage`/`getCoverUri`/`touchBook`/ + `updateProgress`),另外保留頂層 `generateId`(不放進 `local`,因為不是 storage 操作, + bookmark/annotation 的 id 產生也共用這支,仍是 RN 端沿用多年的 `Date.now()+Math.random()` + 自製版本,**沒有**改用 `crypto.randomUUID()`——pwa-next/renderer 那邊用得到是瀏覽器 + 環境保證支援,RN/Hermes 環境沒有把握一定可用,沒有裝置可驗證前不冒這個風險換掉)。 +- `progressService.ts` / `bookmarkService.ts` / `annotationService.ts` / `settingsService.ts`: + 各自封裝對應的 `AsyncStorage` key 與 CRUD(`load`/`save`,另外新增 `clear`,下段說明原因), + 型別(`Bookmark`/`Annotation`/`BookSettings`)也各自搬進對應檔案。 +- **`removeBook` 的跨 store 清理職責從 `bookService` 移到呼叫端**:原本 `lib/library.ts` 的 + `removeBook` 一次呼叫 `AsyncStorage.multiRemove` 清掉 progress/settings/bookmarks/ + annotations 四把 key,是典型的「service 跨界戳別人 storage」。比照 pwa-next 的分工原則 + (跨 service 的組合邏輯留在呼叫端,不塞進單一 service),新的 `bookService.local.removeBook` + 只處理書本本身(metadata/epub 檔/封面圖),另外四個 service 各自新增 + `local.clear(bookId)`;實際清理由 `app/(tabs)/index.tsx` 的 `handleDeleteBook` 用 + `Promise.all` 一次呼叫全部五個。**這點跟 pwa-next 本身不同**——pwa-next 的 + `hooks/useLibrary.ts` 的 `removeBook` 其實沒有清 progress/bookmarks/annotations 的 + key(只清 file/cover/settings),是那邊留著沒補的既有缺口;mobile 原本就有做完整清理, + 這次保留這個正確行為,只是把职责搬到跨 service 的呼叫端,沒有跟著 pwa-next 退化。 +- 沒有新增 `hooks/useLibrary.ts`:mobile 的 `addBook` 沒有 pwa-next 那種「內容雜湊去重/ + 重複匯入接回舊資料」的三分支控制流程(每次都是挑檔案就建新記錄),不需要額外一層 + hook 做編排,`app/(tabs)/index.tsx` 本身就是直接呼叫 service 的呼叫端,沿用原本架構。 +- 七個呼叫端(`app/(tabs)/index.tsx`/`components/BookCard.tsx`/`components/ListPanel.tsx`/ + `hooks/reader/useAnnotations.ts`/`hooks/reader/useBookmarks.ts`/ + `hooks/reader/useReaderEngine.ts`/`lib/reader/calculations.ts`)全部改成呼叫對應 + service,原本 `lib/library.ts` 已刪除,`grep` 確認沒有殘留引用。 +- `yarn tsc --noEmit` 通過;mobile 沒有 `lint` script。使用者已在裝置/模擬器實測「書櫃列表/ + 加書/刪書/開書讀取進度/排版設定/加書籤/劃線註記」確認都正常,已 commit+push 到 `dev`。 ### 收尾(未開始) 三平台的本機資料層一致後,交叉檢查三個子專案的 Service 層介面形狀是否夠接近、有沒有值得抽共用 @@ -159,6 +190,12 @@ pwa-next 的 `addBook` 內容雜湊去重 id 策略、`annotationService` 的 `u renderer/mobile 之後只做本機儲存版的分層。當次對話完成 pwa-next 的全面拆除(見上方 「已放棄的雲端同步」段落),`yarn build`/`yarn lint` 驗證通過,**尚未經使用者瀏覽器實測**, 依規則等驗證通過才能 commit。 +- 2026-08-19:接手做 mobile 的本機資料層分層(前一輪只完成 renderer)。讀完 pwa-next 現有 + 四個 service 的實際寫法(非文件描述)跟 mobile `lib/library.ts` 全部呼叫端後動手拆分, + 過程中發現 `removeBook` 跨 store 清理的職責邊界跟 pwa-next 不同(mobile 原本有做、 + pwa-next 沒做),判斷保留 mobile 既有的完整清理行為比削減到跟 pwa-next 一致更正確, + 於是新增 `clear(bookId)` 到四個非 book service、把清理職責搬到呼叫端而非直接砍掉。 + `yarn tsc --noEmit` 通過,使用者在裝置/模擬器實測確認 OK 後,已 commit+push 到 `dev`。 - 2026-08-18(另一次對話):使用者選擇先做 renderer 的本機資料層分層。比對 pwa-next 現有 `services/*.ts`/`hooks/reader/use*.ts` 的實際寫法(非文件描述,直接讀程式碼)後動手, 發現 pwa-next 早已把 annotation 從 Zustand 換成 `useAnnotations(bookId)` hook(理由記在 diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 0b52192..64a6901 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -5,16 +5,11 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { WebView } from 'react-native-webview'; import BookCard from '../../components/BookCard'; import SortControl, { type SortKey } from '../../components/SortControl'; -import { - addBook, - type BookRecord, - getBookBase64, - listBooks, - removeBook, - saveCoverImage, - seedDefaultBook, - updateBookMeta, -} from '../../lib/library'; +import { bookService, type BookRecord } from '../../services/bookService'; +import { progressService } from '../../services/progressService'; +import { bookmarkService } from '../../services/bookmarkService'; +import { annotationService } from '../../services/annotationService'; +import { settingsService } from '../../services/settingsService'; import { READER_HTML } from '../../lib/readerHtml.generated'; import type { OutboundMessage } from '../../lib/readerMessages'; import { useTheme } from '../../lib/theme'; @@ -41,7 +36,7 @@ const LibraryScreen = () => { } | null>(null); const refresh = useCallback(async () => { - setBooks(await listBooks()); + setBooks(await bookService.local.listBooks()); setLoading(false); }, []); @@ -74,7 +69,7 @@ const LibraryScreen = () => { }; const send = async () => { - const base64 = await getBookBase64(record); + const base64 = await bookService.local.getBookBase64(record); if (__DEV__) console.log('[library] sending extractMeta', { id: record.id, base64Length: base64.length }); extractorRef.current?.postMessage(JSON.stringify({ type: 'extractMeta', base64 })); }; @@ -94,7 +89,7 @@ const LibraryScreen = () => { useFocusEffect( useCallback(() => { - seedDefaultBook().then((record) => { + bookService.local.seedDefaultBook().then((record) => { if (!record) return; refresh(); extractMetaFor(record).then(refresh); @@ -133,13 +128,13 @@ const LibraryScreen = () => { // 封面寫檔失敗(例如解碼異常)不應該連帶丟掉已經擷取到的書名/作者,兩者分開處理。 if (msg.coverBase64) { try { - patch.coverFilename = saveCoverImage(pending.id, msg.coverBase64, msg.coverMediaType); + patch.coverFilename = bookService.local.saveCoverImage(pending.id, msg.coverBase64, msg.coverMediaType); } catch (err) { console.warn('[library] saveCoverImage failed', err); } } if (Object.keys(patch).length > 0) { - updateBookMeta(pending.id, patch) + bookService.local.updateBookMeta(pending.id, patch) .then(refresh) .catch((err) => console.warn('[library] updateBookMeta failed', err)); } @@ -158,7 +153,7 @@ const LibraryScreen = () => { if (addingRef.current) return; addingRef.current = true; try { - const record = await addBook(); + const record = await bookService.local.addBook(); if (!record) return; refresh(); await extractMetaFor(record); @@ -178,7 +173,15 @@ const LibraryScreen = () => { style: 'destructive', onPress: async () => { try { - await removeBook(record.id); + // 書本 metadata/epub 檔案/封面圖歸 bookService 管;閱讀進度/排版設定/ + // 書籤/註記各自存在獨立的 service,刪書時逐一清掉,避免留下孤兒資料。 + await Promise.all([ + bookService.local.removeBook(record.id), + progressService.local.clear(record.id), + bookmarkService.local.clear(record.id), + annotationService.local.clear(record.id), + settingsService.local.clear(record.id), + ]); refresh(); } catch { Alert.alert('刪除失敗', '請稍後再試一次'); diff --git a/mobile/components/BookCard.tsx b/mobile/components/BookCard.tsx index 20d9757..3283ad1 100644 --- a/mobile/components/BookCard.tsx +++ b/mobile/components/BookCard.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; -import { getCoverUri, type BookRecord } from '../lib/library'; +import { bookService, type BookRecord } from '../services/bookService'; import { coverStyleFor } from '../lib/coverStyles'; import { useTheme } from '../lib/theme'; @@ -19,7 +19,7 @@ const BookCard = ({ record, width, onPress, onDelete }: Props) => { const pct = Math.round((record.progress ?? 0) * 100); const style = coverStyleFor(record.id); const coverHeight = Math.round(width * 1.5); - const coverUri = useMemo(() => getCoverUri(record), [record.coverFilename, record.coverUri]); + const coverUri = useMemo(() => bookService.local.getCoverUri(record), [record.coverFilename, record.coverUri]); return ( diff --git a/mobile/components/ListPanel.tsx b/mobile/components/ListPanel.tsx index 36e661e..4d64ef8 100644 --- a/mobile/components/ListPanel.tsx +++ b/mobile/components/ListPanel.tsx @@ -3,7 +3,9 @@ import { useMemo, useRef, useState } from 'react'; import { Image, Pressable, ScrollView, Share, StyleSheet, Text, TextInput, View } from 'react-native'; import { IconCopy } from './icons'; import { HIGHLIGHT_COLORS } from '../lib/annotationColors'; -import { getCoverUri, type Annotation, type BookRecord, type Bookmark } from '../lib/library'; +import { bookService, type BookRecord } from '../services/bookService'; +import type { Annotation } from '../services/annotationService'; +import type { Bookmark } from '../services/bookmarkService'; import type { TocItem } from '../lib/readerMessages'; import { coverStyleFor } from '../lib/coverStyles'; import { useTheme } from '../lib/theme'; @@ -130,7 +132,7 @@ const ListPanel = ({ const cs = record ? coverStyleFor(record.id) : null; const pct = record?.progress != null ? Math.round(record.progress * 100) : null; - const coverUri = useMemo(() => (record ? getCoverUri(record) : null), [record?.coverFilename, record?.coverUri]); + const coverUri = useMemo(() => (record ? bookService.local.getCoverUri(record) : null), [record?.coverFilename, record?.coverUri]); return ( diff --git a/mobile/hooks/reader/useAnnotations.ts b/mobile/hooks/reader/useAnnotations.ts index a0d507d..645d3a4 100644 --- a/mobile/hooks/reader/useAnnotations.ts +++ b/mobile/hooks/reader/useAnnotations.ts @@ -1,4 +1,5 @@ -import { type Annotation, generateId, saveAnnotations } from '../../lib/library'; +import { generateId } from '../../services/bookService'; +import { annotationService, type Annotation } from '../../services/annotationService'; import type { InboundMessage } from '../../lib/readerMessages'; import { addAnnotationList, @@ -47,7 +48,7 @@ export const useAnnotations = ({ }; const next = addAnnotationList(annotations, ann); setAnnotations(next); - saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); + annotationService.local.save(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); syncAnnotationsToWebView(next); setSelection(null); postToWebView({ type: 'clearSelection' }); @@ -57,7 +58,7 @@ export const useAnnotations = ({ if (!id) return; const next = updateAnnotationColorList(annotations, annotationId, color); setAnnotations(next); - saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); + annotationService.local.save(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); syncAnnotationsToWebView(next); }; @@ -65,7 +66,7 @@ export const useAnnotations = ({ if (!id) return; const next = removeAnnotationList(annotations, annotationId); setAnnotations(next); - saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); + annotationService.local.save(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); syncAnnotationsToWebView(next); setEditingAnnotationId(null); }; @@ -74,7 +75,7 @@ export const useAnnotations = ({ if (!id) return; const next = updateAnnotationNoteList(annotations, annotationId, note); setAnnotations(next); - saveAnnotations(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); + annotationService.local.save(id, next).catch((err) => console.error('[reader] saveAnnotations 失敗', err)); }; return { diff --git a/mobile/hooks/reader/useBookmarks.ts b/mobile/hooks/reader/useBookmarks.ts index 903a00c..d5b811f 100644 --- a/mobile/hooks/reader/useBookmarks.ts +++ b/mobile/hooks/reader/useBookmarks.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { type Bookmark, generateId, loadBookmarks, saveBookmarks } from '../../lib/library'; +import { generateId } from '../../services/bookService'; +import { bookmarkService, type Bookmark } from '../../services/bookmarkService'; import { isBookmarked, removeBookmarkList, toggleBookmarkList } from '../../lib/reader/calculations'; export const useBookmarks = (id: string | undefined, currentCfi: string, currentChapterTitle: string) => { @@ -10,7 +11,7 @@ export const useBookmarks = (id: string | undefined, currentCfi: string, current if (!id) return; let cancelled = false; loadedRef.current = false; - loadBookmarks(id).then((saved) => { + bookmarkService.local.load(id).then((saved) => { if (!cancelled) { setBookmarks(saved); loadedRef.current = true; @@ -26,7 +27,7 @@ export const useBookmarks = (id: string | undefined, currentCfi: string, current // 上,不會有兩次呼叫都基於同一份舊 snapshot 而互相蓋掉對方的問題。 useEffect(() => { if (!id || !loadedRef.current) return; - saveBookmarks(id, bookmarks).catch((err) => console.error('[reader] saveBookmarks 失敗', err)); + bookmarkService.local.save(id, bookmarks).catch((err) => console.error('[reader] saveBookmarks 失敗', err)); }, [id, bookmarks]); const handleToggleBookmark = () => { diff --git a/mobile/hooks/reader/useReaderEngine.ts b/mobile/hooks/reader/useReaderEngine.ts index bf4fa9f..bbd72d5 100644 --- a/mobile/hooks/reader/useReaderEngine.ts +++ b/mobile/hooks/reader/useReaderEngine.ts @@ -1,20 +1,10 @@ import { useFocusEffect } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { WebView } from 'react-native-webview'; -import { - type Annotation, - type BookRecord, - type BookSettings, - getBookBase64, - listBooks, - loadAnnotations, - loadBookSettings, - loadReadingCfi, - saveBookSettings, - saveReadingCfi, - touchBook, - updateProgress, -} from '../../lib/library'; +import { bookService, type BookRecord } from '../../services/bookService'; +import { annotationService, type Annotation } from '../../services/annotationService'; +import { progressService } from '../../services/progressService'; +import { settingsService, type BookSettings } from '../../services/settingsService'; import type { InboundMessage, OutboundMessage, TocItem } from '../../lib/readerMessages'; import { DEFAULT_TYPOGRAPHY } from '../../lib/readerSettings'; @@ -54,7 +44,7 @@ export const useReaderEngine = (id: string | undefined, darkMode: boolean, optio useFocusEffect( useCallback(() => { if (!id) return; - touchBook(id); + bookService.local.touchBook(id); }, [id]) ); @@ -69,7 +59,7 @@ export const useReaderEngine = (id: string | undefined, darkMode: boolean, optio settingsLoadedRef.current = false; hadSavedSettingsRef.current = false; let cancelled = false; - loadBookSettings(id).then((saved) => { + settingsService.local.load(id).then((saved) => { if (cancelled) return; if (saved) { setTypography(saved); @@ -86,7 +76,7 @@ export const useReaderEngine = (id: string | undefined, darkMode: boolean, optio useEffect(() => { if (!id || !settingsLoadedRef.current) return; - saveBookSettings(id, typography); + settingsService.local.save(id, typography); }, [id, typography]); useEffect(() => { @@ -108,7 +98,7 @@ export const useReaderEngine = (id: string | undefined, darkMode: boolean, optio const handleWebViewReady = useCallback(async () => { if (!id) return; - const books = await listBooks(); + const books = await bookService.local.listBooks(); const found = books.find((b) => b.id === id) ?? null; setRecord(found); if (!found) { @@ -118,9 +108,9 @@ export const useReaderEngine = (id: string | undefined, darkMode: boolean, optio } try { const [base64, cfi, savedAnnotations] = await Promise.all([ - getBookBase64(found), - loadReadingCfi(id), - loadAnnotations(id), + bookService.local.getBookBase64(found), + progressService.local.load(id), + annotationService.local.load(id), ]); setAnnotations(savedAnnotations); postToWebView({ @@ -165,8 +155,8 @@ export const useReaderEngine = (id: string | undefined, darkMode: boolean, optio relocatedResolverRef.current?.(); relocatedResolverRef.current = null; if (!id) return; - saveReadingCfi(id, msg.cfi); - updateProgress(id, msg.percentage); + progressService.local.save(id, msg.cfi); + bookService.local.updateProgress(id, msg.percentage); return; } if (msg.type === 'tocLoaded') { diff --git a/mobile/lib/reader/calculations.ts b/mobile/lib/reader/calculations.ts index 920b6d9..b0ca77d 100644 --- a/mobile/lib/reader/calculations.ts +++ b/mobile/lib/reader/calculations.ts @@ -1,4 +1,5 @@ -import type { Annotation, Bookmark } from '../library'; +import type { Annotation } from '../../services/annotationService'; +import type { Bookmark } from '../../services/bookmarkService'; import type { TTSVoice } from '../tts'; export const isBookmarked = (bookmarks: Bookmark[], cfi: string): boolean => diff --git a/mobile/services/annotationService.ts b/mobile/services/annotationService.ts new file mode 100644 index 0000000..63cfca3 --- /dev/null +++ b/mobile/services/annotationService.ts @@ -0,0 +1,30 @@ +// Service 層:註記(劃線+筆記)的本機(AsyncStorage)CRUD,不 import React。 +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export interface Annotation { + id: string; + cfi: string; + text: string; + color: string; + chapter: string; + createdAt: number; + note?: string; +} + +const annotationsKey = (bookId: string) => `tit:annotations:${bookId}`; + +const local = { + load: async (bookId: string): Promise => { + try { + const raw = await AsyncStorage.getItem(annotationsKey(bookId)); + return raw ? (JSON.parse(raw) as Annotation[]) : []; + } catch { + return []; + } + }, + save: (bookId: string, annotations: Annotation[]) => + AsyncStorage.setItem(annotationsKey(bookId), JSON.stringify(annotations)), + clear: (bookId: string) => AsyncStorage.removeItem(annotationsKey(bookId)), +}; + +export const annotationService = { local }; diff --git a/mobile/lib/library.ts b/mobile/services/bookService.ts similarity index 71% rename from mobile/lib/library.ts rename to mobile/services/bookService.ts index 39be079..41add61 100644 --- a/mobile/lib/library.ts +++ b/mobile/services/bookService.ts @@ -1,3 +1,5 @@ +// Service 層:書本 metadata/檔案/封面的本機(AsyncStorage + 檔案系統)CRUD,不 import React。 +// 比照 pwa-next services/bookService.ts 現在的本機版形狀({ local: {...} })。 import AsyncStorage from '@react-native-async-storage/async-storage'; import { Asset } from 'expo-asset'; import { Directory, File, Paths } from 'expo-file-system'; @@ -15,38 +17,8 @@ export interface BookRecord { coverFilename?: string; } -export interface Bookmark { - id: string; - cfi: string; - label: string; - addedAt: number; -} - -export interface Annotation { - id: string; - cfi: string; - text: string; - color: string; - chapter: string; - createdAt: number; - note?: string; -} - -export interface BookSettings { - fontSize: number; - fontFamily: string; - script: 'tc' | 'sc'; - lineHeight: number; - letterSpacing: number; - readingDirection: 'ltr' | 'rtl'; -} - const META_KEY = 'tit:library:meta'; const DEFAULT_BOOK_SEEDED_KEY = 'tit:defaultBookSeeded'; -const progressKey = (id: string) => `tit:progress:${id}`; -const settingsKey = (id: string) => `tit:settings:${id}`; -const bookmarksKey = (id: string) => `tit:bookmarks:${id}`; -const annotationsKey = (id: string) => `tit:annotations:${id}`; const booksDir = () => new Directory(Paths.document, 'books'); const coversDir = () => new Directory(Paths.document, 'covers'); @@ -104,9 +76,9 @@ const updateMeta = (fn: (records: BookRecord[]) => [BookRecord[], T]): Promis return result; }; -export const listBooks = (): Promise => loadMeta(); +const listBooks = (): Promise => loadMeta(); -export const addBook = async (): Promise => { +const addBook = async (): Promise => { const result = await File.pickFileAsync({ mimeTypes: ['application/epub+zip'] }); if (result.canceled || !result.result) return null; const picked = result.result as File; @@ -137,7 +109,7 @@ export const addBook = async (): Promise => { // (例如 useFocusEffect 短時間內被叫兩次),避免植入兩份範例書。 let seedDefaultBookPromise: Promise | null = null; -export const seedDefaultBook = (): Promise => { +const seedDefaultBook = (): Promise => { if (!seedDefaultBookPromise) { seedDefaultBookPromise = (async () => { const alreadySeeded = await AsyncStorage.getItem(DEFAULT_BOOK_SEEDED_KEY); @@ -171,16 +143,18 @@ export const seedDefaultBook = (): Promise => { return seedDefaultBookPromise; }; -export const getBookFileUri = (record: BookRecord): string => new File(booksDir(), record.filename).uri; +const getBookFileUri = (record: BookRecord): string => new File(booksDir(), record.filename).uri; -export const getBookBase64 = (record: BookRecord): Promise => +const getBookBase64 = (record: BookRecord): Promise => new File(booksDir(), record.filename).base64(); const resolveCoverFilename = (record: BookRecord): string | undefined => record.coverFilename ?? record.coverUri?.split('/').pop(); -export const removeBook = async (id: string) => { - await AsyncStorage.multiRemove([progressKey(id), settingsKey(id), bookmarksKey(id), annotationsKey(id)]); +// 只清理書本本身的 metadata/epub 檔案/封面圖;閱讀進度/排版設定/書籤/註記存在 +// 各自的 service 裡,交叉刪除的順序由呼叫端(app/(tabs)/index.tsx 的 handleDeleteBook) +// 一次呼叫所有相關 service 的 clear 來完成,避免這裡跨 service 直接戳對方的 storage key。 +const removeBook = async (id: string) => { return updateMeta((records) => { const record = records.find((r) => r.id === id); if (record) { @@ -196,7 +170,7 @@ export const removeBook = async (id: string) => { }); }; -export const updateBookMeta = ( +const updateBookMeta = ( id: string, patch: Partial> ) => @@ -210,7 +184,7 @@ export const updateBookMeta = ( // 重新安裝/原生重新編譯後容器路徑會變,存死的完整路徑就會失效(見 getCoverUri 現算路徑)。 const coverExtensions = ['jpg', 'png', 'gif', 'webp']; -export const saveCoverImage = (id: string, base64: string, mediaType: string | null): string => { +const saveCoverImage = (id: string, base64: string, mediaType: string | null): string => { const dir = coversDir(); if (!dir.exists) dir.create({ intermediates: true }); for (const ext of coverExtensions) { @@ -226,58 +200,34 @@ export const saveCoverImage = (id: string, base64: string, mediaType: string | n // 比照 getBookFileUri 的做法:每次都用目前這次執行的 coversDir() 現算完整路徑, // 不依賴存死的絕對路徑。若只有舊版留下的 coverUri(完整路徑),退而求其次取檔名部分 // 拼回目前的 coversDir(),讓舊資料在 App 重新安裝後也有機會恢復顯示。 -export const getCoverUri = (record: BookRecord): string | null => { +const getCoverUri = (record: BookRecord): string | null => { const filename = resolveCoverFilename(record); if (!filename) return null; const file = new File(coversDir(), filename); return file.exists ? file.uri : null; }; -export const touchBook = (id: string) => +const touchBook = (id: string) => updateMeta((records) => [records.map((r) => (r.id === id ? { ...r, lastOpenedAt: Date.now() } : r)), undefined]); -export const updateProgress = (id: string, pct: number) => +const updateProgress = (id: string, pct: number) => updateMeta((records) => [ records.map((r) => (r.id === id ? { ...r, progress: Math.max(0, Math.min(1, pct)) } : r)), undefined, ]); -export const saveReadingCfi = (id: string, cfi: string) => AsyncStorage.setItem(progressKey(id), cfi); - -export const loadReadingCfi = (id: string): Promise => AsyncStorage.getItem(progressKey(id)); - -export const saveBookSettings = (id: string, settings: BookSettings) => - AsyncStorage.setItem(settingsKey(id), JSON.stringify(settings)); - -export const loadBookSettings = async (id: string): Promise => { - try { - const raw = await AsyncStorage.getItem(settingsKey(id)); - return raw ? (JSON.parse(raw) as BookSettings) : null; - } catch { - return null; - } -}; - -export const loadBookmarks = async (id: string): Promise => { - try { - const raw = await AsyncStorage.getItem(bookmarksKey(id)); - return raw ? (JSON.parse(raw) as Bookmark[]) : []; - } catch { - return []; - } -}; - -export const saveBookmarks = (id: string, bookmarks: Bookmark[]) => - AsyncStorage.setItem(bookmarksKey(id), JSON.stringify(bookmarks)); - -export const loadAnnotations = async (id: string): Promise => { - try { - const raw = await AsyncStorage.getItem(annotationsKey(id)); - return raw ? (JSON.parse(raw) as Annotation[]) : []; - } catch { - return []; - } +const local = { + listBooks, + addBook, + seedDefaultBook, + getBookFileUri, + getBookBase64, + removeBook, + updateBookMeta, + saveCoverImage, + getCoverUri, + touchBook, + updateProgress, }; -export const saveAnnotations = (id: string, annotations: Annotation[]) => - AsyncStorage.setItem(annotationsKey(id), JSON.stringify(annotations)); +export const bookService = { local }; diff --git a/mobile/services/bookmarkService.ts b/mobile/services/bookmarkService.ts new file mode 100644 index 0000000..1cd23f2 --- /dev/null +++ b/mobile/services/bookmarkService.ts @@ -0,0 +1,27 @@ +// Service 層:書籤的本機(AsyncStorage)CRUD,不 import React。 +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export interface Bookmark { + id: string; + cfi: string; + label: string; + addedAt: number; +} + +const bookmarksKey = (bookId: string) => `tit:bookmarks:${bookId}`; + +const local = { + load: async (bookId: string): Promise => { + try { + const raw = await AsyncStorage.getItem(bookmarksKey(bookId)); + return raw ? (JSON.parse(raw) as Bookmark[]) : []; + } catch { + return []; + } + }, + save: (bookId: string, bookmarks: Bookmark[]) => + AsyncStorage.setItem(bookmarksKey(bookId), JSON.stringify(bookmarks)), + clear: (bookId: string) => AsyncStorage.removeItem(bookmarksKey(bookId)), +}; + +export const bookmarkService = { local }; diff --git a/mobile/services/progressService.ts b/mobile/services/progressService.ts new file mode 100644 index 0000000..7103293 --- /dev/null +++ b/mobile/services/progressService.ts @@ -0,0 +1,12 @@ +// Service 層:純函式,本機閱讀進度存取(AsyncStorage),不 import React。 +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const progressKey = (bookId: string) => `tit:progress:${bookId}`; + +const local = { + load: (bookId: string): Promise => AsyncStorage.getItem(progressKey(bookId)), + save: (bookId: string, cfi: string) => AsyncStorage.setItem(progressKey(bookId), cfi), + clear: (bookId: string) => AsyncStorage.removeItem(progressKey(bookId)), +}; + +export const progressService = { local }; diff --git a/mobile/services/settingsService.ts b/mobile/services/settingsService.ts new file mode 100644 index 0000000..c45a7fd --- /dev/null +++ b/mobile/services/settingsService.ts @@ -0,0 +1,29 @@ +// Service 層:書本排版偏好的本機(AsyncStorage)CRUD,不 import React。 +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export interface BookSettings { + fontSize: number; + fontFamily: string; + script: 'tc' | 'sc'; + lineHeight: number; + letterSpacing: number; + readingDirection: 'ltr' | 'rtl'; +} + +const settingsKey = (bookId: string) => `tit:settings:${bookId}`; + +const local = { + load: async (bookId: string): Promise => { + try { + const raw = await AsyncStorage.getItem(settingsKey(bookId)); + return raw ? (JSON.parse(raw) as BookSettings) : null; + } catch { + return null; + } + }, + save: (bookId: string, settings: BookSettings) => + AsyncStorage.setItem(settingsKey(bookId), JSON.stringify(settings)), + clear: (bookId: string) => AsyncStorage.removeItem(settingsKey(bookId)), +}; + +export const settingsService = { local }; From 84861dafc98fcd530d0002c97ce7dbbf7f171924 Mon Sep 17 00:00:00 2001 From: Retsomm <112182ssss@gmail.com> Date: Wed, 19 Aug 2026 11:13:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(mobile):=20=E5=88=AA=E6=9B=B8=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E5=85=88=E6=B8=85=E5=81=B4=E8=B3=87=E6=96=99=E5=86=8D?= =?UTF-8?q?=E5=88=AA=E6=9B=B8=E6=9C=AC=EF=BC=8C=E9=81=BF=E5=85=8D=E4=BD=B5?= =?UTF-8?q?=E7=99=BC=E4=B8=8B=E7=95=99=E5=AD=A4=E5=85=92=E8=B3=87=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeBook 內含檔案刪除,失敗機率比單純的 clear() 高;跟四個 clear 併發時若書本先被刪、某個 clear 卻失敗,會留下永遠沒人指到的孤兒資料。 改成 clear 全部成功後才呼叫 removeBook,失敗時書本留在書櫃裡可重試。 Co-Authored-By: Claude Sonnet 5 --- CLIENT_SERVICE_HOOK_REFACTOR.md | 8 ++++++-- mobile/app/(tabs)/index.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CLIENT_SERVICE_HOOK_REFACTOR.md b/CLIENT_SERVICE_HOOK_REFACTOR.md index 457ec96..c38f7d6 100644 --- a/CLIENT_SERVICE_HOOK_REFACTOR.md +++ b/CLIENT_SERVICE_HOOK_REFACTOR.md @@ -159,8 +159,12 @@ pwa-next 的 `addBook` 內容雜湊去重 id 策略、`annotationService` 的 `u annotations 四把 key,是典型的「service 跨界戳別人 storage」。比照 pwa-next 的分工原則 (跨 service 的組合邏輯留在呼叫端,不塞進單一 service),新的 `bookService.local.removeBook` 只處理書本本身(metadata/epub 檔/封面圖),另外四個 service 各自新增 - `local.clear(bookId)`;實際清理由 `app/(tabs)/index.tsx` 的 `handleDeleteBook` 用 - `Promise.all` 一次呼叫全部五個。**這點跟 pwa-next 本身不同**——pwa-next 的 + `local.clear(bookId)`;實際清理由 `app/(tabs)/index.tsx` 的 `handleDeleteBook` 先用 + `Promise.all` 呼叫四個 `clear`,全部成功後才呼叫 `removeBook`——`removeBook` 內含檔案 + 刪除,失敗機率比單純的 `AsyncStorage.removeItem` 高,若跟四個 clear 併發、書本先被刪 + 但某個 clear 失敗,會留下永遠沒人指到的孤兒資料;讓 clear 先跑,失敗時就不呼叫 + `removeBook`,書本會留在書櫃裡(可重試),不會產生無聲的永久洩漏。 + **這點跟 pwa-next 本身不同**——pwa-next 的 `hooks/useLibrary.ts` 的 `removeBook` 其實沒有清 progress/bookmarks/annotations 的 key(只清 file/cover/settings),是那邊留著沒補的既有缺口;mobile 原本就有做完整清理, 這次保留這個正確行為,只是把职责搬到跨 service 的呼叫端,沒有跟著 pwa-next 退化。 diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 64a6901..f71c1a1 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -175,13 +175,18 @@ const LibraryScreen = () => { try { // 書本 metadata/epub 檔案/封面圖歸 bookService 管;閱讀進度/排版設定/ // 書籤/註記各自存在獨立的 service,刪書時逐一清掉,避免留下孤兒資料。 + // 四個 clear 必須先於 removeBook 完成:removeBook 內含檔案刪除,失敗機率比單純 + // 的 AsyncStorage.removeItem 高,若跟 clear 併發、書本先被刪但某個 clear 失敗, + // 該筆進度/書籤/註記/設定會變成永遠沒人指到的孤兒資料;反過來若 clear 先失敗 + // 就不呼叫 removeBook,書本會留在書櫃裡(資料被重置但使用者看得到、能重試), + // 不會產生無聲的永久洩漏。 await Promise.all([ - bookService.local.removeBook(record.id), progressService.local.clear(record.id), bookmarkService.local.clear(record.id), annotationService.local.clear(record.id), settingsService.local.clear(record.id), ]); + await bookService.local.removeBook(record.id); refresh(); } catch { Alert.alert('刪除失敗', '請稍後再試一次');