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:
Huang Xin 2026-06-17 01:25:01 +08:00 committed by GitHub
parent 757ed8066b
commit 480ab5b71e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 636 additions and 110 deletions

View file

@ -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).

View file

@ -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": "مزامنة تلقائية"
}

View file

@ -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": "স্বয়ংক্রিয় সিঙ্ক"
}

View file

@ -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": "རང་འགུལ་སྤྲི་དོན།"
}

View file

@ -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"
}

View file

@ -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": "Αυτόματος συγχρονισμός"
}

View file

@ -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"
}

View file

@ -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": "همگام‌سازی خودکار"
}

View file

@ -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"
}

View file

@ -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": "סנכרון אוטומטי"
}

View file

@ -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": "स्वतः सिंक"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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": "自動同期"
}

View file

@ -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": "자동 동기화"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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ă"
}

View file

@ -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": "Автосинхронизация"
}

View file

@ -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": "ස්වයංක්‍රීය සමමුහුර්තය"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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": "தானியங்கு ஒத்திசைவு"
}

View file

@ -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": "ซิงค์อัตโนมัติ"
}

View file

@ -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"
}

View file

@ -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": "Автоматична синхронізація"
}

View file

@ -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"
}

View file

@ -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ộ"
}

View file

@ -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": "自动同步"
}

View file

@ -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": "自動同步"
}

View 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);
});
});

View file

@ -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 };
};

View file

@ -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>

View file

@ -84,6 +84,7 @@ export const DEFAULT_HARDCOVER_SETTINGS = {
enabled: false,
accessToken: '',
lastSyncedAt: 0,
autoSync: false,
} as HardcoverSettings;
export const DEFAULT_WEBDAV_SETTINGS = {

View file

@ -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 {