mirror of
https://github.com/readest/readest.git
synced 2026-07-10 00:12:43 +00:00
feat(hardcover): automatically sync progress and notes (#4614)
Hardcover sync previously only ran when the user opened the reader menu and tapped "Push Progress" / "Push Notes". Add an opt-in Auto Sync toggle (default OFF) to the Hardcover settings so progress and notes are pushed automatically while reading. - useHardcoverSync: silent debounced (10s) auto-push of progress on page turns and of notes on annotation/excerpt changes, gated on enabled && autoSync === true; pending pushes flush on the existing sync-book-progress close event and cancel on unmount. Manual menu actions are unchanged (still loud). - HardcoverSettings.autoSync flag (default false); existing connected users stay manual until they opt in. - HardcoverForm: new "Auto Sync" toggle row. Also backfills two untranslated strings surfaced by i18n:extract from the reading-stats feature (#4606) across all locales, plus the new "Auto Sync" key. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
757ed8066b
commit
480ab5b71e
39 changed files with 636 additions and 110 deletions
|
|
@ -0,0 +1,93 @@
|
|||
# Automatic Hardcover Sync
|
||||
|
||||
Date: 2026-06-16
|
||||
Branch: `feat/hardcover-auto-sync`
|
||||
|
||||
## Problem
|
||||
|
||||
Hardcover sync (reading progress + notes) only runs when the user opens the
|
||||
reader menu and taps "Push Progress" / "Push Notes". Users expect their
|
||||
Hardcover account to update on its own as they read, the way the cloud,
|
||||
KOReader, and Readwise integrations already do.
|
||||
|
||||
## Goal
|
||||
|
||||
Sync Hardcover progress and notes automatically while reading, gated by a new
|
||||
**Auto Sync** toggle in the Hardcover settings. The existing menu actions stay
|
||||
as a manual "sync now" force.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Scope**: both reading progress and notes/highlights (mirrors the two manual
|
||||
actions).
|
||||
- **Control**: a single new `autoSync` toggle, **default OFF** (explicit
|
||||
opt-in). One toggle covers both progress and notes — no per-category
|
||||
sub-toggles.
|
||||
- **Write-only**: no auto-pull. Hardcover sync is push-only today (like
|
||||
Readwise); this change keeps that.
|
||||
|
||||
## Design
|
||||
|
||||
### Settings (`src/types/settings.ts`, `src/services/constants.ts`)
|
||||
|
||||
```ts
|
||||
export interface HardcoverSettings {
|
||||
enabled: boolean;
|
||||
accessToken: string;
|
||||
lastSyncedAt: number;
|
||||
autoSync?: boolean; // NEW — default OFF (undefined ⇒ off)
|
||||
}
|
||||
```
|
||||
|
||||
- `DEFAULT_HARDCOVER_SETTINGS` gets `autoSync: false`.
|
||||
- Auto-sync runs only when `enabled && hardcover.autoSync === true` (strict).
|
||||
- Migration: existing connected users have `autoSync === undefined` ⇒ OFF until
|
||||
they opt in. No behavior change on upgrade.
|
||||
|
||||
### Hook (`src/app/reader/hooks/useHardcoverSync.ts`)
|
||||
|
||||
Follows the Readwise / `useProgressSync` patterns already in the codebase.
|
||||
|
||||
- `pushProgress` / `pushNotes` gain a `{ silent?: boolean }` option. In silent
|
||||
mode the success/info toasts are suppressed and errors go to `console.error`
|
||||
only (matches Readwise's debounced path). Manual runs stay loud.
|
||||
- Two `useMemo` + `debounce` (10s) auto-pushers, each reading settings at call
|
||||
time and gated on `enabled && autoSync === true`:
|
||||
- `debouncedPushProgress` — triggered by a reactive `useBookProgress(bookKey)`
|
||||
`location` change (page turns); calls `pushProgress({ silent: true })`.
|
||||
- `debouncedPushNotes` — triggered by `config?.booknotes` change; calls
|
||||
`pushNotes({ silent: true })`.
|
||||
- Flush on close: the existing `sync-book-progress` event (dispatched in
|
||||
`ReaderContent.saveBookConfig` before unmount) flushes both debouncers so a
|
||||
quick close still sends pending work. No change to ReaderContent.
|
||||
- On unmount, cancel both debouncers (no stray network calls after teardown).
|
||||
- Keep the existing `hardcover-push-progress` / `hardcover-push-notes` manual
|
||||
listeners (toasts retained).
|
||||
|
||||
### Settings UI (`src/components/settings/integrations/HardcoverForm.tsx`)
|
||||
|
||||
Add a second `min-h-14` toggle row "Auto Sync" inside the existing card under
|
||||
"Sync Enabled":
|
||||
|
||||
- `checked={settings.hardcover?.autoSync === true}` (unchecked by default).
|
||||
- `handleToggleAutoSync` mirrors `handleToggleEnabled`.
|
||||
- `handleConnect` sets `autoSync: settings.hardcover?.autoSync ?? false`.
|
||||
|
||||
## Testing (TDD)
|
||||
|
||||
New `src/__tests__/hooks/useHardcoverSync.test.tsx` using the `renderHook` +
|
||||
mocked-store + fake-timers harness from `useProgressSync.test.tsx`:
|
||||
|
||||
1. progress change + `autoSync: true` → after debounce, `client.pushProgress`
|
||||
called once, no success toast (silent).
|
||||
2. `autoSync` off/absent → `client.pushProgress` NOT called after debounce.
|
||||
3. booknotes change + `autoSync: true` → `client.syncBookNotes` called after
|
||||
debounce.
|
||||
4. `sync-book-progress` event flushes a pending push immediately.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Auto-pull from Hardcover.
|
||||
- Per-category (progress vs notes) sub-toggles.
|
||||
- Throttling `lastSyncedAt` settings writes (kept consistent with the existing
|
||||
manual `pushProgress` / Readwise behavior).
|
||||
|
|
@ -1885,5 +1885,8 @@
|
|||
"Early daily builds": "إصدارات يومية مبكرة",
|
||||
"Privacy": "الخصوصية",
|
||||
"No downloadable format available": "لا يتوفر تنسيق قابل للتنزيل",
|
||||
"Content is neither valid XML nor JSON": "المحتوى ليس بصيغة XML ولا JSON صالحة"
|
||||
"Content is neither valid XML nor JSON": "المحتوى ليس بصيغة XML ولا JSON صالحة",
|
||||
"Reading statistics": "إحصائيات القراءة",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "وقت القراءة والصفحات المقروءة، تتم مزامنتها عبر أجهزتك و KOReader.",
|
||||
"Auto Sync": "مزامنة تلقائية"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "প্রাথমিক দৈনিক বিল্ড",
|
||||
"Privacy": "গোপনীয়তা",
|
||||
"No downloadable format available": "ডাউনলোডযোগ্য কোনো ফরম্যাট নেই",
|
||||
"Content is neither valid XML nor JSON": "বিষয়বস্তু বৈধ XML বা JSON কোনোটিই নয়"
|
||||
"Content is neither valid XML nor JSON": "বিষয়বস্তু বৈধ XML বা JSON কোনোটিই নয়",
|
||||
"Reading statistics": "পড়ার পরিসংখ্যান",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "পড়ার সময় এবং পড়া পৃষ্ঠা, আপনার ডিভাইস এবং KOReader জুড়ে সিঙ্ক করা হয়।",
|
||||
"Auto Sync": "স্বয়ংক্রিয় সিঙ্ক"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1720,5 +1720,8 @@
|
|||
"Early daily builds": "སྔ་མོའི་ཉིན་རེའི་བཟོ་སྐྲུན།",
|
||||
"Privacy": "གསང་དོན།",
|
||||
"No downloadable format available": "ཕབ་ལེན་བྱེད་ཆོག་པའི་རྣམ་པ་མི་འདུག",
|
||||
"Content is neither valid XML nor JSON": "ནང་དོན་ནི་ XML དང་ JSON གང་ཡང་ཚད་ལྡན་མིན།"
|
||||
"Content is neither valid XML nor JSON": "ནང་དོན་ནི་ XML དང་ JSON གང་ཡང་ཚད་ལྡན་མིན།",
|
||||
"Reading statistics": "ཀློག་འདོན་གྱི་གྲངས་ཐོ།",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "ཀློག་འདོན་གྱི་དུས་ཚོད་དང་ཀློག་ཟིན་པའི་ཤོག་གྲངས། ཁྱེད་ཀྱི་སྒྲིག་ཆས་དང་ KOReader བར་མཉམ་སྒྲིག་བྱེད།",
|
||||
"Auto Sync": "རང་འགུལ་སྤྲི་དོན།"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "Frühe tägliche Builds",
|
||||
"Privacy": "Datenschutz",
|
||||
"No downloadable format available": "Kein herunterladbares Format verfügbar",
|
||||
"Content is neither valid XML nor JSON": "Inhalt ist weder gültiges XML noch JSON"
|
||||
"Content is neither valid XML nor JSON": "Inhalt ist weder gültiges XML noch JSON",
|
||||
"Reading statistics": "Lesestatistik",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Lesezeit und gelesene Seiten, synchronisiert über deine Geräte und KOReader.",
|
||||
"Auto Sync": "Automatische Synchronisierung"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "Πρώιμες καθημερινές εκδόσεις",
|
||||
"Privacy": "Απόρρητο",
|
||||
"No downloadable format available": "Δεν υπάρχει διαθέσιμη μορφή για λήψη",
|
||||
"Content is neither valid XML nor JSON": "Το περιεχόμενο δεν είναι ούτε έγκυρο XML ούτε JSON"
|
||||
"Content is neither valid XML nor JSON": "Το περιεχόμενο δεν είναι ούτε έγκυρο XML ούτε JSON",
|
||||
"Reading statistics": "Στατιστικά ανάγνωσης",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Χρόνος ανάγνωσης και σελίδες που διαβάστηκαν, συγχρονισμένα στις συσκευές σας και το KOReader.",
|
||||
"Auto Sync": "Αυτόματος συγχρονισμός"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1786,5 +1786,8 @@
|
|||
"Early daily builds": "Compilaciones diarias anticipadas",
|
||||
"Privacy": "Privacidad",
|
||||
"No downloadable format available": "No hay ningún formato descargable disponible",
|
||||
"Content is neither valid XML nor JSON": "El contenido no es XML ni JSON válido"
|
||||
"Content is neither valid XML nor JSON": "El contenido no es XML ni JSON válido",
|
||||
"Reading statistics": "Estadísticas de lectura",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Tiempo de lectura y páginas leídas, sincronizados entre tus dispositivos y KOReader.",
|
||||
"Auto Sync": "Sincronización automática"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "نسخههای روزانهٔ آزمایشی",
|
||||
"Privacy": "حریم خصوصی",
|
||||
"No downloadable format available": "هیچ قالب قابل دانلودی موجود نیست",
|
||||
"Content is neither valid XML nor JSON": "محتوا نه XML معتبر است و نه JSON"
|
||||
"Content is neither valid XML nor JSON": "محتوا نه XML معتبر است و نه JSON",
|
||||
"Reading statistics": "آمار مطالعه",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "زمان مطالعه و صفحات خواندهشده، همگامسازیشده در دستگاههای شما و KOReader.",
|
||||
"Auto Sync": "همگامسازی خودکار"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1786,5 +1786,8 @@
|
|||
"Early daily builds": "Versions quotidiennes anticipées",
|
||||
"Privacy": "Confidentialité",
|
||||
"No downloadable format available": "Aucun format téléchargeable disponible",
|
||||
"Content is neither valid XML nor JSON": "Le contenu n'est ni du XML ni du JSON valide"
|
||||
"Content is neither valid XML nor JSON": "Le contenu n'est ni du XML ni du JSON valide",
|
||||
"Reading statistics": "Statistiques de lecture",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Temps de lecture et pages lues, synchronisés sur vos appareils et KOReader.",
|
||||
"Auto Sync": "Synchronisation automatique"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1786,5 +1786,8 @@
|
|||
"Early daily builds": "גרסאות יומיות מוקדמות",
|
||||
"Privacy": "פרטיות",
|
||||
"No downloadable format available": "אין פורמט להורדה זמין",
|
||||
"Content is neither valid XML nor JSON": "התוכן אינו XML או JSON תקין"
|
||||
"Content is neither valid XML nor JSON": "התוכן אינו XML או JSON תקין",
|
||||
"Reading statistics": "סטטיסטיקת קריאה",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "זמן קריאה ועמודים שנקראו, מסונכרנים בין המכשירים שלך ו-KOReader.",
|
||||
"Auto Sync": "סנכרון אוטומטי"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "प्रारंभिक दैनिक बिल्ड",
|
||||
"Privacy": "गोपनीयता",
|
||||
"No downloadable format available": "कोई डाउनलोड करने योग्य प्रारूप उपलब्ध नहीं है",
|
||||
"Content is neither valid XML nor JSON": "सामग्री न तो मान्य XML है और न ही JSON"
|
||||
"Content is neither valid XML nor JSON": "सामग्री न तो मान्य XML है और न ही JSON",
|
||||
"Reading statistics": "पठन सांख्यिकी",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "पढ़ने का समय और पढ़े गए पृष्ठ, आपके डिवाइसों और KOReader के बीच सिंक किए गए।",
|
||||
"Auto Sync": "स्वतः सिंक"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "Korai napi buildek",
|
||||
"Privacy": "Adatvédelem",
|
||||
"No downloadable format available": "Nincs letölthető formátum",
|
||||
"Content is neither valid XML nor JSON": "A tartalom sem érvényes XML, sem JSON"
|
||||
"Content is neither valid XML nor JSON": "A tartalom sem érvényes XML, sem JSON",
|
||||
"Reading statistics": "Olvasási statisztika",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Olvasási idő és elolvasott oldalak, szinkronizálva az eszközei és a KOReader között.",
|
||||
"Auto Sync": "Automatikus szinkronizálás"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1720,5 +1720,8 @@
|
|||
"Early daily builds": "Build harian awal",
|
||||
"Privacy": "Privasi",
|
||||
"No downloadable format available": "Tidak ada format yang dapat diunduh",
|
||||
"Content is neither valid XML nor JSON": "Konten bukan XML maupun JSON yang valid"
|
||||
"Content is neither valid XML nor JSON": "Konten bukan XML maupun JSON yang valid",
|
||||
"Reading statistics": "Statistik Bacaan",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Waktu membaca dan halaman yang dibaca, disinkronkan di seluruh perangkat Anda dan KOReader.",
|
||||
"Auto Sync": "Sinkronisasi Otomatis"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1786,5 +1786,8 @@
|
|||
"Early daily builds": "Build giornaliere anticipate",
|
||||
"Privacy": "Privacy",
|
||||
"No downloadable format available": "Nessun formato scaricabile disponibile",
|
||||
"Content is neither valid XML nor JSON": "Il contenuto non è né XML né JSON valido"
|
||||
"Content is neither valid XML nor JSON": "Il contenuto non è né XML né JSON valido",
|
||||
"Reading statistics": "Statistiche di lettura",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Tempo di lettura e pagine lette, sincronizzati tra i tuoi dispositivi e KOReader.",
|
||||
"Auto Sync": "Sincronizzazione automatica"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1720,5 +1720,8 @@
|
|||
"Early daily builds": "毎日の先行ビルド",
|
||||
"Privacy": "プライバシー",
|
||||
"No downloadable format available": "ダウンロード可能な形式がありません",
|
||||
"Content is neither valid XML nor JSON": "コンテンツが有効な XML でも JSON でもありません"
|
||||
"Content is neither valid XML nor JSON": "コンテンツが有効な XML でも JSON でもありません",
|
||||
"Reading statistics": "読書統計",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "読書時間と読んだページ数。デバイス間および KOReader と同期されます。",
|
||||
"Auto Sync": "自動同期"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1720,5 +1720,8 @@
|
|||
"Early daily builds": "매일 제공되는 미리 보기 빌드",
|
||||
"Privacy": "개인정보 보호",
|
||||
"No downloadable format available": "다운로드 가능한 형식이 없습니다",
|
||||
"Content is neither valid XML nor JSON": "콘텐츠가 유효한 XML 또는 JSON이 아닙니다"
|
||||
"Content is neither valid XML nor JSON": "콘텐츠가 유효한 XML 또는 JSON이 아닙니다",
|
||||
"Reading statistics": "독서 통계",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "읽은 시간과 읽은 페이지 수로, 여러 기기와 KOReader 간에 동기화됩니다.",
|
||||
"Auto Sync": "자동 동기화"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1720,5 +1720,8 @@
|
|||
"Early daily builds": "Binaan harian awal",
|
||||
"Privacy": "Privasi",
|
||||
"No downloadable format available": "Tiada format yang boleh dimuat turun",
|
||||
"Content is neither valid XML nor JSON": "Kandungan bukan XML mahupun JSON yang sah"
|
||||
"Content is neither valid XML nor JSON": "Kandungan bukan XML mahupun JSON yang sah",
|
||||
"Reading statistics": "Statistik Bacaan",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Masa membaca dan halaman dibaca, disegerakkan merentas peranti anda dan KOReader.",
|
||||
"Auto Sync": "Segerak Automatik"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "Vroege dagelijkse builds",
|
||||
"Privacy": "Privacy",
|
||||
"No downloadable format available": "Geen downloadbaar formaat beschikbaar",
|
||||
"Content is neither valid XML nor JSON": "Inhoud is geen geldige XML of JSON"
|
||||
"Content is neither valid XML nor JSON": "Inhoud is geen geldige XML of JSON",
|
||||
"Reading statistics": "Leesstatistieken",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Leestijd en gelezen pagina's, gesynchroniseerd op je apparaten en KOReader.",
|
||||
"Auto Sync": "Automatische synchronisatie"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1819,5 +1819,8 @@
|
|||
"Early daily builds": "Wczesne kompilacje dzienne",
|
||||
"Privacy": "Prywatność",
|
||||
"No downloadable format available": "Brak formatu do pobrania",
|
||||
"Content is neither valid XML nor JSON": "Treść nie jest prawidłowym XML ani JSON"
|
||||
"Content is neither valid XML nor JSON": "Treść nie jest prawidłowym XML ani JSON",
|
||||
"Reading statistics": "Statystyki czytania",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Czas czytania i przeczytane strony, synchronizowane między Twoimi urządzeniami a KOReader.",
|
||||
"Auto Sync": "Automatyczna synchronizacja"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1786,5 +1786,8 @@
|
|||
"Early daily builds": "Compilações diárias antecipadas",
|
||||
"Privacy": "Privacidade",
|
||||
"No downloadable format available": "Nenhum formato disponível para download",
|
||||
"Content is neither valid XML nor JSON": "O conteúdo não é XML nem JSON válido"
|
||||
"Content is neither valid XML nor JSON": "O conteúdo não é XML nem JSON válido",
|
||||
"Reading statistics": "Estatísticas de leitura",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Tempo de leitura e páginas lidas, sincronizados entre seus dispositivos e o KOReader.",
|
||||
"Auto Sync": "Sincronização automática"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1786,5 +1786,8 @@
|
|||
"Early daily builds": "Compilações diárias antecipadas",
|
||||
"Privacy": "Privacidade",
|
||||
"No downloadable format available": "Nenhum formato disponível para download",
|
||||
"Content is neither valid XML nor JSON": "O conteúdo não é XML nem JSON válido"
|
||||
"Content is neither valid XML nor JSON": "O conteúdo não é XML nem JSON válido",
|
||||
"Reading statistics": "Estatísticas de leitura",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Tempo de leitura e páginas lidas, sincronizados entre os seus dispositivos e o KOReader.",
|
||||
"Auto Sync": "Sincronização Automática"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1786,5 +1786,8 @@
|
|||
"Early daily builds": "Versiuni zilnice timpurii",
|
||||
"Privacy": "Confidențialitate",
|
||||
"No downloadable format available": "Niciun format descărcabil disponibil",
|
||||
"Content is neither valid XML nor JSON": "Conținutul nu este nici XML, nici JSON valid"
|
||||
"Content is neither valid XML nor JSON": "Conținutul nu este nici XML, nici JSON valid",
|
||||
"Reading statistics": "Statistici de lectură",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Timpul de citire și paginile citite, sincronizate pe dispozitivele tale și KOReader.",
|
||||
"Auto Sync": "Sincronizare automată"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1819,5 +1819,8 @@
|
|||
"Early daily builds": "Ранние ежедневные сборки",
|
||||
"Privacy": "Конфиденциальность",
|
||||
"No downloadable format available": "Нет доступного формата для загрузки",
|
||||
"Content is neither valid XML nor JSON": "Содержимое не является ни корректным XML, ни JSON"
|
||||
"Content is neither valid XML nor JSON": "Содержимое не является ни корректным XML, ни JSON",
|
||||
"Reading statistics": "Статистика чтения",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Время чтения и прочитанные страницы, синхронизируются между вашими устройствами и KOReader.",
|
||||
"Auto Sync": "Автосинхронизация"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "මුල් දෛනික නිකුතු",
|
||||
"Privacy": "පෞද්ගලිකත්වය",
|
||||
"No downloadable format available": "බාගත කළ හැකි ආකෘතියක් නොමැත",
|
||||
"Content is neither valid XML nor JSON": "අන්තර්ගතය වලංගු XML හෝ JSON නොවේ"
|
||||
"Content is neither valid XML nor JSON": "අන්තර්ගතය වලංගු XML හෝ JSON නොවේ",
|
||||
"Reading statistics": "කියවීමේ සංඛ්යාලේඛන",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "කියවීමේ කාලය සහ කියවූ පිටු, ඔබේ උපාංග සහ KOReader හරහා සමමුහුර්ත වේ.",
|
||||
"Auto Sync": "ස්වයංක්රීය සමමුහුර්තය"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1819,5 +1819,8 @@
|
|||
"Early daily builds": "Zgodnje dnevne gradnje",
|
||||
"Privacy": "Zasebnost",
|
||||
"No downloadable format available": "Na voljo ni nobene oblike za prenos",
|
||||
"Content is neither valid XML nor JSON": "Vsebina ni veljaven XML niti JSON"
|
||||
"Content is neither valid XML nor JSON": "Vsebina ni veljaven XML niti JSON",
|
||||
"Reading statistics": "Statistika branja",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Čas branja in prebrane strani, sinhronizirano med vašimi napravami in KOReader.",
|
||||
"Auto Sync": "Samodejna sinhronizacija"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "Tidiga dagliga versioner",
|
||||
"Privacy": "Integritet",
|
||||
"No downloadable format available": "Inget nedladdningsbart format tillgängligt",
|
||||
"Content is neither valid XML nor JSON": "Innehållet är varken giltig XML eller JSON"
|
||||
"Content is neither valid XML nor JSON": "Innehållet är varken giltig XML eller JSON",
|
||||
"Reading statistics": "Lässtatistik",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Lästid och lästa sidor, synkroniserat mellan dina enheter och KOReader.",
|
||||
"Auto Sync": "Automatisk synkronisering"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "ஆரம்ப தினசரி உருவாக்கங்கள்",
|
||||
"Privacy": "தனியுரிமை",
|
||||
"No downloadable format available": "பதிவிறக்கம் செய்யக்கூடிய வடிவம் எதுவும் இல்லை",
|
||||
"Content is neither valid XML nor JSON": "உள்ளடக்கம் சரியான XML அல்லது JSON அல்ல"
|
||||
"Content is neither valid XML nor JSON": "உள்ளடக்கம் சரியான XML அல்லது JSON அல்ல",
|
||||
"Reading statistics": "வாசிப்பு புள்ளிவிவரங்கள்",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "வாசிப்பு நேரம் மற்றும் படித்த பக்கங்கள், உங்கள் சாதனங்கள் மற்றும் KOReader இடையே ஒத்திசைக்கப்படும்.",
|
||||
"Auto Sync": "தானியங்கு ஒத்திசைவு"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1720,5 +1720,8 @@
|
|||
"Early daily builds": "บิลด์รายวันรุ่นแรก",
|
||||
"Privacy": "ความเป็นส่วนตัว",
|
||||
"No downloadable format available": "ไม่มีรูปแบบที่ดาวน์โหลดได้",
|
||||
"Content is neither valid XML nor JSON": "เนื้อหาไม่ใช่ทั้ง XML และ JSON ที่ถูกต้อง"
|
||||
"Content is neither valid XML nor JSON": "เนื้อหาไม่ใช่ทั้ง XML และ JSON ที่ถูกต้อง",
|
||||
"Reading statistics": "สถิติการอ่าน",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "เวลาในการอ่านและจำนวนหน้าที่อ่าน ซิงค์ข้ามอุปกรณ์ของคุณและ KOReader",
|
||||
"Auto Sync": "ซิงค์อัตโนมัติ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "Erken günlük sürümler",
|
||||
"Privacy": "Gizlilik",
|
||||
"No downloadable format available": "İndirilebilir biçim yok",
|
||||
"Content is neither valid XML nor JSON": "İçerik geçerli XML veya JSON değil"
|
||||
"Content is neither valid XML nor JSON": "İçerik geçerli XML veya JSON değil",
|
||||
"Reading statistics": "Okuma İstatistikleri",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Okuma süresi ve okunan sayfalar, cihazlarınız ve KOReader arasında senkronize edilir.",
|
||||
"Auto Sync": "Otomatik Senkronizasyon"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1819,5 +1819,8 @@
|
|||
"Early daily builds": "Ранні щоденні збірки",
|
||||
"Privacy": "Конфіденційність",
|
||||
"No downloadable format available": "Немає доступного формату для завантаження",
|
||||
"Content is neither valid XML nor JSON": "Вміст не є дійсним XML чи JSON"
|
||||
"Content is neither valid XML nor JSON": "Вміст не є дійсним XML чи JSON",
|
||||
"Reading statistics": "Статистика читання",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Час читання та прочитані сторінки, синхронізуються між вашими пристроями та KOReader.",
|
||||
"Auto Sync": "Автоматична синхронізація"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1753,5 +1753,8 @@
|
|||
"Early daily builds": "Erta kunlik buildlar",
|
||||
"Privacy": "Maxfiylik",
|
||||
"No downloadable format available": "Yuklab olinadigan format mavjud emas",
|
||||
"Content is neither valid XML nor JSON": "Kontent yaroqli XML ham, JSON ham emas"
|
||||
"Content is neither valid XML nor JSON": "Kontent yaroqli XML ham, JSON ham emas",
|
||||
"Reading statistics": "Oʻqish statistikasi",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Oʻqish vaqti va oʻqilgan sahifalar, qurilmalaringiz va KOReader oʻrtasida sinxronlanadi.",
|
||||
"Auto Sync": "Avtomatik sinxronlash"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1720,5 +1720,8 @@
|
|||
"Early daily builds": "Bản dựng hằng ngày sớm",
|
||||
"Privacy": "Quyền riêng tư",
|
||||
"No downloadable format available": "Không có định dạng nào để tải xuống",
|
||||
"Content is neither valid XML nor JSON": "Nội dung không phải XML hay JSON hợp lệ"
|
||||
"Content is neither valid XML nor JSON": "Nội dung không phải XML hay JSON hợp lệ",
|
||||
"Reading statistics": "Thống kê đọc",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "Thời gian đọc và số trang đã đọc, được đồng bộ trên các thiết bị của bạn và KOReader.",
|
||||
"Auto Sync": "Tự động đồng bộ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1720,5 +1720,8 @@
|
|||
"Early daily builds": "每日抢先构建版",
|
||||
"Privacy": "隐私",
|
||||
"No downloadable format available": "没有可下载的格式",
|
||||
"Content is neither valid XML nor JSON": "内容既不是有效的 XML 也不是 JSON"
|
||||
"Content is neither valid XML nor JSON": "内容既不是有效的 XML 也不是 JSON",
|
||||
"Reading statistics": "阅读统计",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "阅读时间和已读页数,在您的设备和 KOReader 之间同步。",
|
||||
"Auto Sync": "自动同步"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1720,5 +1720,8 @@
|
|||
"Early daily builds": "每日搶先建置版",
|
||||
"Privacy": "隱私",
|
||||
"No downloadable format available": "沒有可下載的格式",
|
||||
"Content is neither valid XML nor JSON": "內容既不是有效的 XML 也不是 JSON"
|
||||
"Content is neither valid XML nor JSON": "內容既不是有效的 XML 也不是 JSON",
|
||||
"Reading statistics": "閱讀統計",
|
||||
"Reading time and pages read, synced across your devices and KOReader.": "閱讀時間與已讀頁數,在您的裝置與 KOReader 之間同步。",
|
||||
"Auto Sync": "自動同步"
|
||||
}
|
||||
|
|
|
|||
209
apps/readest-app/src/__tests__/hooks/useHardcoverSync.test.tsx
Normal file
209
apps/readest-app/src/__tests__/hooks/useHardcoverSync.test.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
|
||||
const HARDCOVER_SYNC_DEBOUNCE_MS = 10000;
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
const makeStore = <T,>(state: T) => {
|
||||
const fn = <R,>(selector?: (s: T) => R) => (selector ? selector(state) : state) as R | T;
|
||||
(fn as unknown as { getState: () => T }).getState = () => state;
|
||||
return fn as {
|
||||
(): T;
|
||||
<R>(selector: (s: T) => R): R;
|
||||
getState: () => T;
|
||||
};
|
||||
};
|
||||
|
||||
const book = { hash: 'h1', format: 'EPUB', metaHash: 'm1' };
|
||||
|
||||
return {
|
||||
makeStore,
|
||||
book,
|
||||
// Mutable settings — tests flip `hardcover.autoSync` between renders.
|
||||
settings: {
|
||||
hardcover: {
|
||||
enabled: true,
|
||||
accessToken: 'tok',
|
||||
autoSync: false,
|
||||
lastSyncedAt: 0,
|
||||
} as {
|
||||
enabled: boolean;
|
||||
accessToken: string;
|
||||
autoSync?: boolean;
|
||||
lastSyncedAt: number;
|
||||
},
|
||||
},
|
||||
config: {
|
||||
progress: [5, 100] as [number, number],
|
||||
booknotes: [] as Array<{ type: string; deletedAt?: number }>,
|
||||
},
|
||||
state: {
|
||||
progress: { location: 'cfi-loc' } as { location: string } | null,
|
||||
},
|
||||
setSettingsMock: vi.fn(),
|
||||
saveSettingsMock: vi.fn(async () => {}),
|
||||
pushProgressMock: vi.fn(async () => {}),
|
||||
syncBookNotesMock: vi.fn(async () => ({ inserted: 0, updated: 0, skipped: 0 })),
|
||||
toasts: [] as Array<{ message: string; type: string }>,
|
||||
eventListeners: new Map<string, Set<(e: CustomEvent) => void>>(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ envConfig: { getAppService: async () => ({}) } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (s: string) => s,
|
||||
}));
|
||||
|
||||
vi.mock('@/store/settingsStore', () => ({
|
||||
useSettingsStore: h.makeStore({
|
||||
settings: h.settings,
|
||||
setSettings: h.setSettingsMock,
|
||||
saveSettings: h.saveSettingsMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/bookDataStore', () => ({
|
||||
useBookDataStore: h.makeStore({
|
||||
getConfig: () => h.config,
|
||||
getBookData: () => ({ book: h.book }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/readerProgressStore', () => ({
|
||||
useBookProgress: () => h.state.progress,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/hardcover', () => ({
|
||||
HardcoverClient: class {
|
||||
pushProgress() {
|
||||
return h.pushProgressMock();
|
||||
}
|
||||
syncBookNotes() {
|
||||
return h.syncBookNotesMock();
|
||||
}
|
||||
},
|
||||
HardcoverSyncMapStore: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/event', () => ({
|
||||
eventDispatcher: {
|
||||
on: (name: string, fn: (e: CustomEvent) => void) => {
|
||||
const set = h.eventListeners.get(name) ?? new Set();
|
||||
set.add(fn);
|
||||
h.eventListeners.set(name, set);
|
||||
},
|
||||
off: (name: string, fn: (e: CustomEvent) => void) => {
|
||||
h.eventListeners.get(name)?.delete(fn);
|
||||
},
|
||||
dispatch: (name: string, detail: unknown) => {
|
||||
if (name === 'toast') h.toasts.push(detail as { message: string; type: string });
|
||||
const listeners = h.eventListeners.get(name);
|
||||
if (!listeners) return;
|
||||
const event = new CustomEvent(name, { detail });
|
||||
for (const fn of [...listeners]) fn(event);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { useHardcoverSync } from '@/app/reader/hooks/useHardcoverSync';
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
const advance = async (ms: number) => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
};
|
||||
|
||||
const dispatch = (name: string, detail: unknown) =>
|
||||
h.eventListeners.get(name)?.forEach((fn) => fn(new CustomEvent(name, { detail })));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
h.settings.hardcover = { enabled: true, accessToken: 'tok', autoSync: false, lastSyncedAt: 0 };
|
||||
h.config = { progress: [5, 100], booknotes: [] };
|
||||
h.state.progress = { location: 'cfi-loc' };
|
||||
h.pushProgressMock.mockClear();
|
||||
h.syncBookNotesMock.mockClear();
|
||||
h.setSettingsMock.mockClear();
|
||||
h.saveSettingsMock.mockClear();
|
||||
h.toasts.length = 0;
|
||||
h.eventListeners.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('useHardcoverSync auto sync', () => {
|
||||
test('pushes progress automatically (silently) on page turn when autoSync is on', async () => {
|
||||
h.settings.hardcover.autoSync = true;
|
||||
const { rerender } = renderHook(() => useHardcoverSync('h1-view1'));
|
||||
|
||||
// Simulate a page turn.
|
||||
h.state.progress = { location: 'cfi-loc-2' };
|
||||
rerender();
|
||||
await advance(HARDCOVER_SYNC_DEBOUNCE_MS + 100);
|
||||
|
||||
expect(h.pushProgressMock).toHaveBeenCalledTimes(1);
|
||||
// Auto-sync must be silent — no success toast on every page turn.
|
||||
expect(h.toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does NOT auto-push progress when autoSync is off', async () => {
|
||||
h.settings.hardcover.autoSync = false;
|
||||
const { rerender } = renderHook(() => useHardcoverSync('h1-view1'));
|
||||
|
||||
h.state.progress = { location: 'cfi-loc-2' };
|
||||
rerender();
|
||||
await advance(HARDCOVER_SYNC_DEBOUNCE_MS + 100);
|
||||
|
||||
expect(h.pushProgressMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('pushes notes automatically when booknotes change and autoSync is on', async () => {
|
||||
h.settings.hardcover.autoSync = true;
|
||||
const { rerender } = renderHook(() => useHardcoverSync('h1-view1'));
|
||||
|
||||
h.config = { progress: [5, 100], booknotes: [{ type: 'annotation' }] };
|
||||
rerender();
|
||||
await advance(HARDCOVER_SYNC_DEBOUNCE_MS + 100);
|
||||
|
||||
expect(h.syncBookNotesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does NOT auto-push notes when autoSync is off', async () => {
|
||||
h.settings.hardcover.autoSync = false;
|
||||
const { rerender } = renderHook(() => useHardcoverSync('h1-view1'));
|
||||
|
||||
h.config = { progress: [5, 100], booknotes: [{ type: 'annotation' }] };
|
||||
rerender();
|
||||
await advance(HARDCOVER_SYNC_DEBOUNCE_MS + 100);
|
||||
|
||||
expect(h.syncBookNotesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sync-book-progress flushes a pending auto-push immediately', async () => {
|
||||
h.settings.hardcover.autoSync = true;
|
||||
const { rerender } = renderHook(() => useHardcoverSync('h1-view1'));
|
||||
|
||||
h.state.progress = { location: 'cfi-loc-2' };
|
||||
rerender();
|
||||
|
||||
// Without advancing the full debounce window, the close-flush event should
|
||||
// force the pending push out.
|
||||
await act(async () => {
|
||||
dispatch('sync-book-progress', { bookKey: 'h1-view1' });
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(h.pushProgressMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,32 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useBookProgress } from '@/store/readerProgressStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { HardcoverClient, HardcoverSyncMapStore } from '@/services/hardcover';
|
||||
import { BookNote } from '@/types/book';
|
||||
|
||||
// Hardcover throttles its API hard (≈1 req/1.15s), and the "currently reading"
|
||||
// status + reading-session progress it tracks doesn't need second-by-second
|
||||
// accuracy, so the auto-sync debounce is deliberately coarse.
|
||||
const HARDCOVER_SYNC_DEBOUNCE_MS = 10000;
|
||||
|
||||
interface PushOptions {
|
||||
// Auto-sync runs silently (errors → console only) so we don't toast on every
|
||||
// page turn; manual menu actions stay loud.
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
export const useHardcoverSync = (bookKey: string) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
// Reactive page-turn signal — drives the auto-push effect below. The host
|
||||
// (Annotator) already subscribes to this, so it adds no extra renders.
|
||||
const progress = useBookProgress(bookKey);
|
||||
|
||||
const updateLastSyncedAt = useCallback(
|
||||
async (timestamp: number) => {
|
||||
|
|
@ -35,91 +51,140 @@ export const useHardcoverSync = (bookKey: string) => {
|
|||
return new HardcoverClient(settings.hardcover, mapStore);
|
||||
}, [envConfig]);
|
||||
|
||||
const pushNotes = useCallback(async () => {
|
||||
const config = getConfig(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!config || !book) return;
|
||||
const pushNotes = useCallback(
|
||||
async (options?: PushOptions) => {
|
||||
const silent = options?.silent ?? false;
|
||||
const config = getConfig(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!config || !book) return;
|
||||
|
||||
const eligibleNotes = (config.booknotes ?? []).filter(
|
||||
(note: BookNote) =>
|
||||
(note.type === 'annotation' || note.type === 'excerpt') && !note.deletedAt,
|
||||
);
|
||||
const eligibleNotes = (config.booknotes ?? []).filter(
|
||||
(note: BookNote) =>
|
||||
(note.type === 'annotation' || note.type === 'excerpt') && !note.deletedAt,
|
||||
);
|
||||
|
||||
if (eligibleNotes.length === 0) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('No annotations or excerpts to sync for this book.'),
|
||||
type: 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (eligibleNotes.length === 0) {
|
||||
if (!silent) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('No annotations or excerpts to sync for this book.'),
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Configure Hardcover in Settings first.'),
|
||||
type: 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
if (!silent) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Configure Hardcover in Settings first.'),
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.syncBookNotes(book, config);
|
||||
try {
|
||||
const result = await client.syncBookNotes(book, config);
|
||||
|
||||
await updateLastSyncedAt(Date.now());
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message:
|
||||
result.inserted === 0 && result.updated === 0
|
||||
? _('No new Hardcover note changes to sync.')
|
||||
: _('Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged', {
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
type: result.inserted === 0 && result.updated === 0 ? 'info' : 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Hardcover notes sync failed:', error);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Hardcover notes sync failed: {{error}}', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}, [_, bookKey, getBookData, getClient, getConfig, updateLastSyncedAt]);
|
||||
await updateLastSyncedAt(Date.now());
|
||||
if (!silent) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message:
|
||||
result.inserted === 0 && result.updated === 0
|
||||
? _('No new Hardcover note changes to sync.')
|
||||
: _(
|
||||
'Hardcover synced: {{inserted}} new, {{updated}} updated, {{skipped}} unchanged',
|
||||
{
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
},
|
||||
),
|
||||
type: result.inserted === 0 && result.updated === 0 ? 'info' : 'success',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Hardcover notes sync failed:', error);
|
||||
if (!silent) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Hardcover notes sync failed: {{error}}', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[_, bookKey, getBookData, getClient, getConfig, updateLastSyncedAt],
|
||||
);
|
||||
|
||||
const pushProgress = useCallback(async () => {
|
||||
const config = getConfig(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!config || !book) return;
|
||||
const pushProgress = useCallback(
|
||||
async (options?: PushOptions) => {
|
||||
const silent = options?.silent ?? false;
|
||||
const config = getConfig(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!config || !book) return;
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Configure Hardcover in Settings first.'),
|
||||
type: 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
if (!silent) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Configure Hardcover in Settings first.'),
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.pushProgress(book, config);
|
||||
await updateLastSyncedAt(Date.now());
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Reading progress synced to Hardcover'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Hardcover progress sync failed:', error);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Hardcover progress sync failed: {{error}}', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}, [_, bookKey, getBookData, getClient, getConfig, updateLastSyncedAt]);
|
||||
try {
|
||||
await client.pushProgress(book, config);
|
||||
await updateLastSyncedAt(Date.now());
|
||||
if (!silent) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Reading progress synced to Hardcover'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Hardcover progress sync failed:', error);
|
||||
if (!silent) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Hardcover progress sync failed: {{error}}', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[_, bookKey, getBookData, getClient, getConfig, updateLastSyncedAt],
|
||||
);
|
||||
|
||||
// Debounced, silent auto-pushers. Settings are read at call time so a freshly
|
||||
// toggled Auto Sync (or a disconnect) takes effect without rebuilding these.
|
||||
const debouncedAutoPushProgress = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
const { settings } = useSettingsStore.getState();
|
||||
if (!settings.hardcover?.enabled || settings.hardcover?.autoSync !== true) return;
|
||||
pushProgress({ silent: true });
|
||||
}, HARDCOVER_SYNC_DEBOUNCE_MS),
|
||||
[pushProgress],
|
||||
);
|
||||
|
||||
const debouncedAutoPushNotes = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
const { settings } = useSettingsStore.getState();
|
||||
if (!settings.hardcover?.enabled || settings.hardcover?.autoSync !== true) return;
|
||||
pushNotes({ silent: true });
|
||||
}, HARDCOVER_SYNC_DEBOUNCE_MS),
|
||||
[pushNotes],
|
||||
);
|
||||
|
||||
// Manual "Push Progress" / "Push Notes" from BookMenu — force a sync now, with
|
||||
// toasts, regardless of the Auto Sync toggle.
|
||||
useEffect(() => {
|
||||
const handlePushNotes = async (event: CustomEvent) => {
|
||||
if (event.detail.bookKey !== bookKey) return;
|
||||
|
|
@ -140,5 +205,41 @@ export const useHardcoverSync = (bookKey: string) => {
|
|||
};
|
||||
}, [bookKey, pushNotes, pushProgress]);
|
||||
|
||||
// Flush any pending auto-push when the book closes (ReaderContent dispatches
|
||||
// 'sync-book-progress' before teardown) or when the user taps the manual
|
||||
// cloud Sync button — so a quick close doesn't drop the pending push.
|
||||
useEffect(() => {
|
||||
const handleFlush = (event: CustomEvent) => {
|
||||
if (event.detail.bookKey !== bookKey) return;
|
||||
debouncedAutoPushProgress.flush();
|
||||
debouncedAutoPushNotes.flush();
|
||||
};
|
||||
eventDispatcher.on('sync-book-progress', handleFlush);
|
||||
return () => {
|
||||
eventDispatcher.off('sync-book-progress', handleFlush);
|
||||
};
|
||||
}, [bookKey, debouncedAutoPushProgress, debouncedAutoPushNotes]);
|
||||
|
||||
// Cancel pending auto-pushes on unmount so they don't fire after teardown.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedAutoPushProgress.cancel();
|
||||
debouncedAutoPushNotes.cancel();
|
||||
};
|
||||
}, [debouncedAutoPushProgress, debouncedAutoPushNotes]);
|
||||
|
||||
// Auto-push progress on page turns.
|
||||
useEffect(() => {
|
||||
debouncedAutoPushProgress();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress?.location]);
|
||||
|
||||
// Auto-push notes when annotations/excerpts change.
|
||||
const config = getConfig(bookKey);
|
||||
useEffect(() => {
|
||||
debouncedAutoPushNotes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config?.booknotes]);
|
||||
|
||||
return { pushNotes, pushProgress };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const HardcoverForm: React.FC<HardcoverFormProps> = ({ onBack }) => {
|
|||
enabled: true,
|
||||
accessToken,
|
||||
lastSyncedAt: settings.hardcover?.lastSyncedAt ?? 0,
|
||||
autoSync: settings.hardcover?.autoSync ?? false,
|
||||
},
|
||||
};
|
||||
setSettings(newSettings);
|
||||
|
|
@ -76,6 +77,15 @@ const HardcoverForm: React.FC<HardcoverFormProps> = ({ onBack }) => {
|
|||
await saveSettings(envConfig, newSettings);
|
||||
};
|
||||
|
||||
const handleToggleAutoSync = async () => {
|
||||
const newSettings = {
|
||||
...settings,
|
||||
hardcover: { ...settings.hardcover, autoSync: !(settings.hardcover?.autoSync === true) },
|
||||
};
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
};
|
||||
|
||||
const lastSyncedAt = settings.hardcover?.lastSyncedAt ?? 0;
|
||||
const lastSyncedLabel = lastSyncedAt ? new Date(lastSyncedAt).toLocaleString() : _('Never');
|
||||
|
||||
|
|
@ -107,6 +117,15 @@ const HardcoverForm: React.FC<HardcoverFormProps> = ({ onBack }) => {
|
|||
onChange={handleToggleEnabled}
|
||||
/>
|
||||
</label>
|
||||
<label className='flex min-h-14 items-center justify-between px-4'>
|
||||
<SettingLabel>{_('Auto Sync')}</SettingLabel>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={settings.hardcover?.autoSync === true}
|
||||
onChange={handleToggleAutoSync}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export const DEFAULT_HARDCOVER_SETTINGS = {
|
|||
enabled: false,
|
||||
accessToken: '',
|
||||
lastSyncedAt: 0,
|
||||
autoSync: false,
|
||||
} as HardcoverSettings;
|
||||
|
||||
export const DEFAULT_WEBDAV_SETTINGS = {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ export interface HardcoverSettings {
|
|||
enabled: boolean;
|
||||
accessToken: string;
|
||||
lastSyncedAt: number;
|
||||
// When true, progress + notes are pushed to Hardcover automatically as the
|
||||
// user reads (debounced) instead of only via the reader menu. Default OFF;
|
||||
// existing connected users (undefined) stay manual until they opt in.
|
||||
autoSync?: boolean;
|
||||
}
|
||||
|
||||
export interface WebDAVSettings {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue