From cbdc3b8f5274dae5c9b2805bd0a88c5bef0649d5 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 7 May 2026 03:39:38 +0800 Subject: [PATCH] feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076) * feat(sync): cross-device dictionary sync Custom MDict / StarDict / DICT / SLOB dictionaries now sync across signed-in devices via the replica layer. - Store mutations publish replica rows with field-level LWW + tombstones. - Re-importing the same content (renamed or after delete) preserves the user's label and reincarnates the server row instead of duplicating. - Manifest commits after binary upload so other devices never see a row whose binaries aren't on cloud storage yet. - Pull-side orchestrator creates a placeholder dict, queues the binaries via TransferManager, and clears the unavailable flag on completion. - Toast copy branches by transfer kind so dict uploads don't read "Book uploaded". Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sync): boot pull and binary download path - Defer the boot pull until TransferManager is initialized so download enqueues aren't dropped. - Auto-persist the local dict store after applyRemoteDictionary; otherwise the next loadCustomDictionaries wipes the in-memory rows. - Boot pull passes since=null so a device whose cursor advanced past unpersisted rows can still recover. - Skip pulling when not authenticated instead of logging "SyncError: Not authenticated" on every boot of a signed-out device. - downloadReplicaFile resolves the destination against the kind's base dir; binaries previously landed at the literal lfp and openFile then failed with "File not found". Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(sync): per-page useReplicaPull hook Lifts the boot-time pull out of EnvContext into a hook each page mounts for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }). Library page and the shared Reader component opt in. The hook fires 10s after page load (so feature mounts hydrate first), dedups per-kind across navigation, and releases the slot on failure so a later mount can retry. Future kinds plug into the hook's per-kind switch. Also closes two refresh-loop bugs: - Hydrate the dict store from settings BEFORE the apply loop, so the auto-persist doesn't clobber persisted rows that the in-memory store hadn't yet read. Library-page refresh was the visible victim. - Skip the download queue when every manifest file is already on disk under the resolved bundle dir. Refreshing is a no-op; partial- download recovery still queues because some files would be missing. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../public/locales/ar/translation.json | 8 +- .../public/locales/bn/translation.json | 8 +- .../public/locales/bo/translation.json | 8 +- .../public/locales/de/translation.json | 8 +- .../public/locales/el/translation.json | 8 +- .../public/locales/es/translation.json | 8 +- .../public/locales/fa/translation.json | 8 +- .../public/locales/fr/translation.json | 8 +- .../public/locales/he/translation.json | 8 +- .../public/locales/hi/translation.json | 8 +- .../public/locales/hu/translation.json | 8 +- .../public/locales/id/translation.json | 8 +- .../public/locales/it/translation.json | 8 +- .../public/locales/ja/translation.json | 8 +- .../public/locales/ko/translation.json | 8 +- .../public/locales/ms/translation.json | 8 +- .../public/locales/nl/translation.json | 8 +- .../public/locales/pl/translation.json | 8 +- .../public/locales/pt-BR/translation.json | 8 +- .../public/locales/pt/translation.json | 8 +- .../public/locales/ro/translation.json | 8 +- .../public/locales/ru/translation.json | 8 +- .../public/locales/si/translation.json | 8 +- .../public/locales/sl/translation.json | 8 +- .../public/locales/sv/translation.json | 8 +- .../public/locales/ta/translation.json | 8 +- .../public/locales/th/translation.json | 8 +- .../public/locales/tr/translation.json | 8 +- .../public/locales/uk/translation.json | 8 +- .../public/locales/uz/translation.json | 8 +- .../public/locales/vi/translation.json | 8 +- .../public/locales/zh-CN/translation.json | 8 +- .../public/locales/zh-TW/translation.json | 8 +- .../components/ProofreadPopup.test.tsx | 4 + .../components/ProofreadRules.test.tsx | 8 +- .../__tests__/hooks/useReplicaPull.test.tsx | 154 +++++++++ .../src/__tests__/libs/crdt.test.ts | 88 +++++ .../__tests__/libs/replicaInterpret.test.ts | 86 +++++ .../dictionaries/dictionaryDedup.test.ts | 291 ++++++++++++++++ .../services/sync/replicaBinaryUpload.test.ts | 154 +++++++++ .../services/sync/replicaCursorStore.test.ts | 132 ++++++++ .../sync/replicaDictionaryApply.test.ts | 192 +++++++++++ .../services/sync/replicaPublish.test.ts | 232 +++++++++++++ .../sync/replicaPullDictionaries.test.ts | 319 ++++++++++++++++++ .../services/sync/replicaSyncManager.test.ts | 113 +++++++ .../sync/replicaTransferIntegration.test.ts | 209 ++++++++++++ .../services/transfer-manager.test.ts | 74 ++++ .../services/transferMessages.test.ts | 71 ++++ .../store/custom-dictionary-store.test.ts | 174 +++++++++- apps/readest-app/src/app/library/page.tsx | 6 + .../src/app/reader/components/Reader.tsx | 6 + .../settings/CustomDictionaries.tsx | 3 + apps/readest-app/src/context/EnvContext.tsx | 22 +- apps/readest-app/src/hooks/useReplicaPull.ts | 143 ++++++++ apps/readest-app/src/libs/crdt.README.md | 6 +- apps/readest-app/src/libs/crdt.ts | 36 +- apps/readest-app/src/libs/replicaInterpret.ts | 19 ++ apps/readest-app/src/services/appService.ts | 9 +- .../services/dictionaries/dictionaryDedup.ts | 103 ++++++ .../dictionaries/dictionaryService.ts | 83 +++-- .../dictionaries/providers/mdictProvider.ts | 1 - .../src/services/dictionaries/types.ts | 12 + .../src/services/settingsService.ts | 5 + .../src/services/sync/replicaBinaryUpload.ts | 61 ++++ .../src/services/sync/replicaCursorStore.ts | 71 ++++ .../services/sync/replicaDictionaryApply.ts | 117 +++++++ .../src/services/sync/replicaPublish.ts | 116 +++++++ .../services/sync/replicaPullDictionaries.ts | 145 ++++++++ .../src/services/sync/replicaSyncManager.ts | 59 +++- .../sync/replicaTransferIntegration.ts | 91 +++++ .../src/services/transferManager.ts | 38 ++- .../src/services/transferMessages.ts | 50 +++ .../src/store/customDictionaryStore.ts | 140 +++++++- apps/readest-app/src/store/transferStore.ts | 3 + apps/readest-app/src/types/settings.ts | 7 + apps/readest-app/src/types/system.ts | 3 +- .../db/migrations/003_add_replicas.sql | 4 +- .../migrations/004_crdt_merge_replica_fn.sql | 2 +- ...005_replica_manifest_cursor_updated_at.sql | 104 ++++++ 79 files changed, 3929 insertions(+), 101 deletions(-) create mode 100644 apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx create mode 100644 apps/readest-app/src/__tests__/libs/replicaInterpret.test.ts create mode 100644 apps/readest-app/src/__tests__/services/dictionaries/dictionaryDedup.test.ts create mode 100644 apps/readest-app/src/__tests__/services/sync/replicaBinaryUpload.test.ts create mode 100644 apps/readest-app/src/__tests__/services/sync/replicaCursorStore.test.ts create mode 100644 apps/readest-app/src/__tests__/services/sync/replicaDictionaryApply.test.ts create mode 100644 apps/readest-app/src/__tests__/services/sync/replicaPublish.test.ts create mode 100644 apps/readest-app/src/__tests__/services/sync/replicaPullDictionaries.test.ts create mode 100644 apps/readest-app/src/__tests__/services/sync/replicaTransferIntegration.test.ts create mode 100644 apps/readest-app/src/__tests__/services/transferMessages.test.ts create mode 100644 apps/readest-app/src/hooks/useReplicaPull.ts create mode 100644 apps/readest-app/src/libs/replicaInterpret.ts create mode 100644 apps/readest-app/src/services/dictionaries/dictionaryDedup.ts create mode 100644 apps/readest-app/src/services/sync/replicaBinaryUpload.ts create mode 100644 apps/readest-app/src/services/sync/replicaCursorStore.ts create mode 100644 apps/readest-app/src/services/sync/replicaDictionaryApply.ts create mode 100644 apps/readest-app/src/services/sync/replicaPublish.ts create mode 100644 apps/readest-app/src/services/sync/replicaPullDictionaries.ts create mode 100644 apps/readest-app/src/services/sync/replicaTransferIntegration.ts create mode 100644 apps/readest-app/src/services/transferMessages.ts create mode 100644 docker/volumes/db/migrations/005_replica_manifest_cursor_updated_at.sql diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index fbee1f31..363b078b 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1428,5 +1428,11 @@ "Edit Dictionary": "تعديل القاموس", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "تستخدم حزم MDict ملفات .mdx؛ ملفات .mdd و .css المرافقة اختيارية.", "Dictionary name": "اسم القاموس", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "لا يمكن تشغيل هذا الصوت هنا — يستخدم القاموس تنسيقًا قديمًا. جرّب قاموسًا يستخدم صوت Opus أو MP3 أو WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "لا يمكن تشغيل هذا الصوت هنا — يستخدم القاموس تنسيقًا قديمًا. جرّب قاموسًا يستخدم صوت Opus أو MP3 أو WAV.", + "Dictionary uploaded: {{title}}": "تم رفع القاموس: {{title}}", + "Dictionary downloaded: {{title}}": "تم تنزيل القاموس: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "تم حذف النسخة السحابية للقاموس: {{title}}", + "Failed to upload dictionary: {{title}}": "فشل رفع القاموس: {{title}}", + "Failed to download dictionary: {{title}}": "فشل تنزيل القاموس: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "فشل حذف النسخة السحابية للقاموس: {{title}}" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 86596a51..f66e5bf3 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "অভিধান সম্পাদনা করুন", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict বান্ডেল .mdx ফাইল ব্যবহার করে; সহগামী .mdd ও .css ফাইল ঐচ্ছিক।", "Dictionary name": "অভিধানের নাম", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "এই অডিও এখানে চালানো যাচ্ছে না — অভিধানটি একটি পুরনো ফর্ম্যাট ব্যবহার করছে। Opus, MP3 বা WAV অডিও সহ একটি ব্যবহার করে দেখুন।" + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "এই অডিও এখানে চালানো যাচ্ছে না — অভিধানটি একটি পুরনো ফর্ম্যাট ব্যবহার করছে। Opus, MP3 বা WAV অডিও সহ একটি ব্যবহার করে দেখুন।", + "Dictionary uploaded: {{title}}": "অভিধান আপলোড করা হয়েছে: {{title}}", + "Dictionary downloaded: {{title}}": "অভিধান ডাউনলোড করা হয়েছে: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "অভিধানের ক্লাউড কপি মুছে ফেলা হয়েছে: {{title}}", + "Failed to upload dictionary: {{title}}": "অভিধান আপলোড করতে ব্যর্থ: {{title}}", + "Failed to download dictionary: {{title}}": "অভিধান ডাউনলোড করতে ব্যর্থ: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "অভিধানের ক্লাউড কপি মুছতে ব্যর্থ: {{title}}" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 853542c9..60fa9072 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1308,5 +1308,11 @@ "Edit Dictionary": "ཚིག་མཛོད་བཟོ་བཅོས།", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict གི་ཡིག་ཆས་ཚོགས་ཀྱིས་ .mdx ཡིག་ཆ་སྤྱོད། སྦྲེལ་མཐུན་ .mdd དང་ .css ཡིག་ཆ་ཡིན་ན་འདེམས་ཆོག", "Dictionary name": "ཚིག་མཛོད་མིང་།", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "སྒྲ་འདི་འདིར་སྒྲོག་མི་ཐུབ — ཚིག་མཛོད་འདིས་རྩིས་མེད་པའི་རྣམ་པ་སྤྱོད། Opus, MP3, ཡང་ན་ WAV གི་སྒྲ་ལྡན་པ་ཞིག་ལ་འདེམས་སྡུར་གྱིས།" + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "སྒྲ་འདི་འདིར་སྒྲོག་མི་ཐུབ — ཚིག་མཛོད་འདིས་རྩིས་མེད་པའི་རྣམ་པ་སྤྱོད། Opus, MP3, ཡང་ན་ WAV གི་སྒྲ་ལྡན་པ་ཞིག་ལ་འདེམས་སྡུར་གྱིས།", + "Dictionary uploaded: {{title}}": "ཚིག་མཛོད་སྤར་འཇུག་ཟིན། {{title}}", + "Dictionary downloaded: {{title}}": "ཚིག་མཛོད་ཕབ་ལེན་ཟིན། {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "སྤྲིན་ཐོག་གི་ཚིག་མཛོད་འདྲ་བཤུས་སུབ་ཟིན། {{title}}", + "Failed to upload dictionary: {{title}}": "ཚིག་མཛོད་སྤར་འཇུག་ལ་ཐུབ་མ་སོང་། {{title}}", + "Failed to download dictionary: {{title}}": "ཚིག་མཛོད་ཕབ་ལེན་ལ་ཐུབ་མ་སོང་། {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "སྤྲིན་ཐོག་གི་ཚིག་མཛོད་འདྲ་བཤུས་སུབ་ཐུབ་མ་སོང་། {{title}}" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index e0c7606d..043a726d 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "Wörterbuch bearbeiten", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict-Bundles verwenden .mdx-Dateien; begleitende .mdd- und .css-Dateien sind optional.", "Dictionary name": "Wörterbuchname", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Diese Audio kann hier nicht abgespielt werden — das Wörterbuch verwendet ein veraltetes Format. Probieren Sie ein Wörterbuch mit Opus-, MP3- oder WAV-Audio." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Diese Audio kann hier nicht abgespielt werden — das Wörterbuch verwendet ein veraltetes Format. Probieren Sie ein Wörterbuch mit Opus-, MP3- oder WAV-Audio.", + "Dictionary uploaded: {{title}}": "Wörterbuch hochgeladen: {{title}}", + "Dictionary downloaded: {{title}}": "Wörterbuch heruntergeladen: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Cloud-Kopie des Wörterbuchs gelöscht: {{title}}", + "Failed to upload dictionary: {{title}}": "Fehler beim Hochladen des Wörterbuchs: {{title}}", + "Failed to download dictionary: {{title}}": "Fehler beim Herunterladen des Wörterbuchs: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Fehler beim Löschen der Cloud-Kopie des Wörterbuchs: {{title}}" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 9ce04f9b..0b3388a5 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "Επεξεργασία λεξικού", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Τα πακέτα MDict χρησιμοποιούν αρχεία .mdx· τα συνοδευτικά αρχεία .mdd και .css είναι προαιρετικά.", "Dictionary name": "Όνομα λεξικού", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Αυτός ο ήχος δεν μπορεί να αναπαραχθεί εδώ — το λεξικό χρησιμοποιεί ξεπερασμένη μορφή. Δοκιμάστε ένα λεξικό με ήχο Opus, MP3 ή WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Αυτός ο ήχος δεν μπορεί να αναπαραχθεί εδώ — το λεξικό χρησιμοποιεί ξεπερασμένη μορφή. Δοκιμάστε ένα λεξικό με ήχο Opus, MP3 ή WAV.", + "Dictionary uploaded: {{title}}": "Το λεξικό μεταφορτώθηκε: {{title}}", + "Dictionary downloaded: {{title}}": "Το λεξικό κατέβηκε: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Διαγράφηκε το αντίγραφο cloud του λεξικού: {{title}}", + "Failed to upload dictionary: {{title}}": "Αποτυχία μεταφόρτωσης λεξικού: {{title}}", + "Failed to download dictionary: {{title}}": "Αποτυχία λήψης λεξικού: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Αποτυχία διαγραφής αντιγράφου cloud του λεξικού: {{title}}" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index e3c09320..a12c721f 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1356,5 +1356,11 @@ "Edit Dictionary": "Editar diccionario", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Los paquetes MDict usan archivos .mdx; los archivos .mdd y .css complementarios son opcionales.", "Dictionary name": "Nombre del diccionario", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este audio no se puede reproducir aquí — el diccionario usa un formato obsoleto. Prueba uno con audio Opus, MP3 o WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este audio no se puede reproducir aquí — el diccionario usa un formato obsoleto. Prueba uno con audio Opus, MP3 o WAV.", + "Dictionary uploaded: {{title}}": "Diccionario subido: {{title}}", + "Dictionary downloaded: {{title}}": "Diccionario descargado: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Copia en la nube del diccionario eliminada: {{title}}", + "Failed to upload dictionary: {{title}}": "No se pudo subir el diccionario: {{title}}", + "Failed to download dictionary: {{title}}": "No se pudo descargar el diccionario: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "No se pudo eliminar la copia en la nube del diccionario: {{title}}" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 2d71a677..225f1e6f 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "ویرایش فرهنگ‌نامه", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "بسته‌های MDict از فایل‌های .mdx استفاده می‌کنند؛ فایل‌های همراه .mdd و .css اختیاری هستند.", "Dictionary name": "نام فرهنگ‌نامه", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "این صدا اینجا قابل پخش نیست — فرهنگ‌نامه از قالبی قدیمی استفاده می‌کند. فرهنگ‌نامه‌ای با صدای Opus، MP3 یا WAV را امتحان کنید." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "این صدا اینجا قابل پخش نیست — فرهنگ‌نامه از قالبی قدیمی استفاده می‌کند. فرهنگ‌نامه‌ای با صدای Opus، MP3 یا WAV را امتحان کنید.", + "Dictionary uploaded: {{title}}": "فرهنگ لغت بارگذاری شد: {{title}}", + "Dictionary downloaded: {{title}}": "فرهنگ لغت دانلود شد: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "نسخهٔ ابری فرهنگ لغت حذف شد: {{title}}", + "Failed to upload dictionary: {{title}}": "بارگذاری فرهنگ لغت ناموفق بود: {{title}}", + "Failed to download dictionary: {{title}}": "دانلود فرهنگ لغت ناموفق بود: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "حذف نسخهٔ ابری فرهنگ لغت ناموفق بود: {{title}}" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index d8044d3d..cfe9657a 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1356,5 +1356,11 @@ "Edit Dictionary": "Modifier le dictionnaire", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Les bundles MDict utilisent des fichiers .mdx ; les fichiers .mdd et .css associés sont facultatifs.", "Dictionary name": "Nom du dictionnaire", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Cet audio ne peut pas être lu ici — le dictionnaire utilise un format obsolète. Essayez-en un avec un audio Opus, MP3 ou WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Cet audio ne peut pas être lu ici — le dictionnaire utilise un format obsolète. Essayez-en un avec un audio Opus, MP3 ou WAV.", + "Dictionary uploaded: {{title}}": "Dictionnaire importé : {{title}}", + "Dictionary downloaded: {{title}}": "Dictionnaire téléchargé : {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Copie cloud du dictionnaire supprimée : {{title}}", + "Failed to upload dictionary: {{title}}": "Échec de l'envoi du dictionnaire : {{title}}", + "Failed to download dictionary: {{title}}": "Échec du téléchargement du dictionnaire : {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Échec de la suppression de la copie cloud du dictionnaire : {{title}}" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index beab0a32..887e3ee8 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1356,5 +1356,11 @@ "Edit Dictionary": "ערוך מילון", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "חבילות MDict משתמשות בקובצי .mdx; קובצי .mdd ו‑.css נלווים הם אופציונליים.", "Dictionary name": "שם המילון", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "לא ניתן להשמיע את השמע הזה כאן — המילון משתמש בפורמט מיושן. נסה מילון עם שמע Opus, MP3 או WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "לא ניתן להשמיע את השמע הזה כאן — המילון משתמש בפורמט מיושן. נסה מילון עם שמע Opus, MP3 או WAV.", + "Dictionary uploaded: {{title}}": "המילון הועלה: {{title}}", + "Dictionary downloaded: {{title}}": "המילון הורד: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "עותק הענן של המילון נמחק: {{title}}", + "Failed to upload dictionary: {{title}}": "העלאת המילון נכשלה: {{title}}", + "Failed to download dictionary: {{title}}": "הורדת המילון נכשלה: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "מחיקת עותק הענן של המילון נכשלה: {{title}}" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index e72107f7..54f17264 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "शब्दकोश संपादित करें", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict बंडल .mdx फ़ाइलों का उपयोग करते हैं; साथ की .mdd और .css फ़ाइलें वैकल्पिक हैं।", "Dictionary name": "शब्दकोश का नाम", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "यह ऑडियो यहाँ नहीं चलाया जा सकता — शब्दकोश पुराने प्रारूप का उपयोग करता है। Opus, MP3 या WAV ऑडियो वाला कोई आज़माएँ।" + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "यह ऑडियो यहाँ नहीं चलाया जा सकता — शब्दकोश पुराने प्रारूप का उपयोग करता है। Opus, MP3 या WAV ऑडियो वाला कोई आज़माएँ।", + "Dictionary uploaded: {{title}}": "शब्दकोश अपलोड हो गया: {{title}}", + "Dictionary downloaded: {{title}}": "शब्दकोश डाउनलोड हो गया: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "शब्दकोश की क्लाउड कॉपी हटाई गई: {{title}}", + "Failed to upload dictionary: {{title}}": "शब्दकोश अपलोड करने में विफल: {{title}}", + "Failed to download dictionary: {{title}}": "शब्दकोश डाउनलोड करने में विफल: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "शब्दकोश की क्लाउड कॉपी हटाने में विफल: {{title}}" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 364dcd1d..0459a4d4 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "Szótár szerkesztése", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Az MDict csomagok .mdx fájlokat használnak; a kísérő .mdd és .css fájlok opcionálisak.", "Dictionary name": "Szótár neve", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Ez a hang itt nem játszható le — a szótár elavult formátumot használ. Próbálj olyat, amely Opus, MP3 vagy WAV hangot tartalmaz." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Ez a hang itt nem játszható le — a szótár elavult formátumot használ. Próbálj olyat, amely Opus, MP3 vagy WAV hangot tartalmaz.", + "Dictionary uploaded: {{title}}": "Szótár feltöltve: {{title}}", + "Dictionary downloaded: {{title}}": "Szótár letöltve: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "A szótár felhőbeli másolata törölve: {{title}}", + "Failed to upload dictionary: {{title}}": "Nem sikerült feltölteni a szótárat: {{title}}", + "Failed to download dictionary: {{title}}": "Nem sikerült letölteni a szótárat: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Nem sikerült törölni a szótár felhőbeli másolatát: {{title}}" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index c620a29a..76c1df6d 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1308,5 +1308,11 @@ "Edit Dictionary": "Edit Kamus", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Paket MDict menggunakan berkas .mdx; berkas .mdd dan .css pendamping bersifat opsional.", "Dictionary name": "Nama kamus", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak dapat diputar di sini — kamus menggunakan format usang. Coba kamus dengan audio Opus, MP3, atau WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak dapat diputar di sini — kamus menggunakan format usang. Coba kamus dengan audio Opus, MP3, atau WAV.", + "Dictionary uploaded: {{title}}": "Kamus diunggah: {{title}}", + "Dictionary downloaded: {{title}}": "Kamus diunduh: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Salinan cloud kamus dihapus: {{title}}", + "Failed to upload dictionary: {{title}}": "Gagal mengunggah kamus: {{title}}", + "Failed to download dictionary: {{title}}": "Gagal mengunduh kamus: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Gagal menghapus salinan cloud kamus: {{title}}" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 7acdda07..33798cf9 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1356,5 +1356,11 @@ "Edit Dictionary": "Modifica dizionario", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "I pacchetti MDict usano file .mdx; i file .mdd e .css di accompagnamento sono facoltativi.", "Dictionary name": "Nome del dizionario", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Questo audio non può essere riprodotto qui — il dizionario usa un formato obsoleto. Prova un dizionario con audio Opus, MP3 o WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Questo audio non può essere riprodotto qui — il dizionario usa un formato obsoleto. Prova un dizionario con audio Opus, MP3 o WAV.", + "Dictionary uploaded: {{title}}": "Dizionario caricato: {{title}}", + "Dictionary downloaded: {{title}}": "Dizionario scaricato: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Copia cloud del dizionario eliminata: {{title}}", + "Failed to upload dictionary: {{title}}": "Caricamento del dizionario non riuscito: {{title}}", + "Failed to download dictionary: {{title}}": "Download del dizionario non riuscito: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Eliminazione della copia cloud del dizionario non riuscita: {{title}}" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index b9a59a4f..eb352208 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1308,5 +1308,11 @@ "Edit Dictionary": "辞書を編集", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict バンドルは .mdx ファイルを使用します。付属の .mdd および .css ファイルは任意です。", "Dictionary name": "辞書名", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "この音声はここで再生できません — 辞書が古い形式を使用しています。Opus、MP3、または WAV 音声を使用する辞書を試してください。" + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "この音声はここで再生できません — 辞書が古い形式を使用しています。Opus、MP3、または WAV 音声を使用する辞書を試してください。", + "Dictionary uploaded: {{title}}": "辞書をアップロードしました: {{title}}", + "Dictionary downloaded: {{title}}": "辞書をダウンロードしました: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "辞書のクラウドコピーを削除しました: {{title}}", + "Failed to upload dictionary: {{title}}": "辞書のアップロードに失敗しました: {{title}}", + "Failed to download dictionary: {{title}}": "辞書のダウンロードに失敗しました: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "辞書のクラウドコピーの削除に失敗しました: {{title}}" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index fa859eb9..5d5ce3b3 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1308,5 +1308,11 @@ "Edit Dictionary": "사전 편집", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict 번들은 .mdx 파일을 사용합니다. 동반된 .mdd 및 .css 파일은 선택 사항입니다.", "Dictionary name": "사전 이름", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "여기서는 이 오디오를 재생할 수 없습니다 — 사전이 오래된 형식을 사용합니다. Opus, MP3 또는 WAV 오디오 사전을 사용해 보세요." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "여기서는 이 오디오를 재생할 수 없습니다 — 사전이 오래된 형식을 사용합니다. Opus, MP3 또는 WAV 오디오 사전을 사용해 보세요.", + "Dictionary uploaded: {{title}}": "사전 업로드 완료: {{title}}", + "Dictionary downloaded: {{title}}": "사전 다운로드 완료: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "사전의 클라우드 사본을 삭제했습니다: {{title}}", + "Failed to upload dictionary: {{title}}": "사전 업로드 실패: {{title}}", + "Failed to download dictionary: {{title}}": "사전 다운로드 실패: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "사전의 클라우드 사본 삭제 실패: {{title}}" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 2adc2f0f..636232a6 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1308,5 +1308,11 @@ "Edit Dictionary": "Sunting Kamus", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Bungkusan MDict menggunakan fail .mdx; fail .mdd dan .css yang menyertainya adalah pilihan.", "Dictionary name": "Nama kamus", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak boleh dimainkan di sini — kamus menggunakan format lapuk. Cuba kamus dengan audio Opus, MP3 atau WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Audio ini tidak boleh dimainkan di sini — kamus menggunakan format lapuk. Cuba kamus dengan audio Opus, MP3 atau WAV.", + "Dictionary uploaded: {{title}}": "Kamus dimuat naik: {{title}}", + "Dictionary downloaded: {{title}}": "Kamus dimuat turun: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Salinan awan kamus dipadam: {{title}}", + "Failed to upload dictionary: {{title}}": "Gagal memuat naik kamus: {{title}}", + "Failed to download dictionary: {{title}}": "Gagal memuat turun kamus: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Gagal memadam salinan awan kamus: {{title}}" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 76520d4e..207a435f 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "Woordenboek bewerken", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict-bundels gebruiken .mdx-bestanden; bijbehorende .mdd- en .css-bestanden zijn optioneel.", "Dictionary name": "Naam van woordenboek", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Deze audio kan hier niet worden afgespeeld — het woordenboek gebruikt een verouderd formaat. Probeer een woordenboek met Opus-, MP3- of WAV-audio." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Deze audio kan hier niet worden afgespeeld — het woordenboek gebruikt een verouderd formaat. Probeer een woordenboek met Opus-, MP3- of WAV-audio.", + "Dictionary uploaded: {{title}}": "Woordenboek geüpload: {{title}}", + "Dictionary downloaded: {{title}}": "Woordenboek gedownload: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Cloudkopie van het woordenboek verwijderd: {{title}}", + "Failed to upload dictionary: {{title}}": "Uploaden van woordenboek mislukt: {{title}}", + "Failed to download dictionary: {{title}}": "Downloaden van woordenboek mislukt: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Verwijderen van cloudkopie van het woordenboek mislukt: {{title}}" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 096ae623..da3fd74e 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1380,5 +1380,11 @@ "Edit Dictionary": "Edytuj słownik", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Pakiety MDict używają plików .mdx; towarzyszące pliki .mdd i .css są opcjonalne.", "Dictionary name": "Nazwa słownika", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tego dźwięku nie można odtworzyć tutaj — słownik używa przestarzałego formatu. Spróbuj słownika z dźwiękiem Opus, MP3 lub WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tego dźwięku nie można odtworzyć tutaj — słownik używa przestarzałego formatu. Spróbuj słownika z dźwiękiem Opus, MP3 lub WAV.", + "Dictionary uploaded: {{title}}": "Słownik przesłany: {{title}}", + "Dictionary downloaded: {{title}}": "Słownik pobrany: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Usunięto kopię słownika w chmurze: {{title}}", + "Failed to upload dictionary: {{title}}": "Nie udało się przesłać słownika: {{title}}", + "Failed to download dictionary: {{title}}": "Nie udało się pobrać słownika: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Nie udało się usunąć kopii słownika w chmurze: {{title}}" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index eca3ac35..d8a488ed 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1356,5 +1356,11 @@ "Edit Dictionary": "Editar dicionário", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Os pacotes MDict usam arquivos .mdx; os arquivos .mdd e .css que os acompanham são opcionais.", "Dictionary name": "Nome do dicionário", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Tente um dicionário com áudio Opus, MP3 ou WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Tente um dicionário com áudio Opus, MP3 ou WAV.", + "Dictionary uploaded: {{title}}": "Dicionário carregado: {{title}}", + "Dictionary downloaded: {{title}}": "Dicionário baixado: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Cópia na nuvem do dicionário excluída: {{title}}", + "Failed to upload dictionary: {{title}}": "Falha ao carregar o dicionário: {{title}}", + "Failed to download dictionary: {{title}}": "Falha ao baixar o dicionário: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Falha ao excluir a cópia na nuvem do dicionário: {{title}}" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 81dabf5b..0983ac48 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1356,5 +1356,11 @@ "Edit Dictionary": "Editar dicionário", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Os pacotes MDict usam ficheiros .mdx; os ficheiros .mdd e .css que os acompanham são opcionais.", "Dictionary name": "Nome do dicionário", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Experimenta um dicionário com áudio Opus, MP3 ou WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Este áudio não pode ser reproduzido aqui — o dicionário usa um formato obsoleto. Experimenta um dicionário com áudio Opus, MP3 ou WAV.", + "Dictionary uploaded: {{title}}": "Dicionário carregado: {{title}}", + "Dictionary downloaded: {{title}}": "Dicionário transferido: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Cópia na nuvem do dicionário eliminada: {{title}}", + "Failed to upload dictionary: {{title}}": "Falha ao carregar o dicionário: {{title}}", + "Failed to download dictionary: {{title}}": "Falha ao transferir o dicionário: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Falha ao eliminar a cópia na nuvem do dicionário: {{title}}" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 753e599d..c66774a8 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1356,5 +1356,11 @@ "Edit Dictionary": "Editează dicționarul", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Pachetele MDict folosesc fișiere .mdx; fișierele .mdd și .css însoțitoare sunt opționale.", "Dictionary name": "Numele dicționarului", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Acest audio nu poate fi redat aici — dicționarul folosește un format învechit. Încearcă unul cu audio Opus, MP3 sau WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Acest audio nu poate fi redat aici — dicționarul folosește un format învechit. Încearcă unul cu audio Opus, MP3 sau WAV.", + "Dictionary uploaded: {{title}}": "Dicționar încărcat: {{title}}", + "Dictionary downloaded: {{title}}": "Dicționar descărcat: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Copia din cloud a dicționarului a fost ștearsă: {{title}}", + "Failed to upload dictionary: {{title}}": "Încărcarea dicționarului a eșuat: {{title}}", + "Failed to download dictionary: {{title}}": "Descărcarea dicționarului a eșuat: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Ștergerea copiei din cloud a dicționarului a eșuat: {{title}}" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 3c755280..8462aad0 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1380,5 +1380,11 @@ "Edit Dictionary": "Редактировать словарь", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Пакеты MDict используют файлы .mdx; сопутствующие файлы .mdd и .css необязательны.", "Dictionary name": "Название словаря", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Этот звук невозможно воспроизвести здесь — словарь использует устаревший формат. Попробуйте словарь с аудио Opus, MP3 или WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Этот звук невозможно воспроизвести здесь — словарь использует устаревший формат. Попробуйте словарь с аудио Opus, MP3 или WAV.", + "Dictionary uploaded: {{title}}": "Словарь загружен: {{title}}", + "Dictionary downloaded: {{title}}": "Словарь скачан: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Облачная копия словаря удалена: {{title}}", + "Failed to upload dictionary: {{title}}": "Не удалось загрузить словарь: {{title}}", + "Failed to download dictionary: {{title}}": "Не удалось скачать словарь: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Не удалось удалить облачную копию словаря: {{title}}" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 52c2e918..b24221a1 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "ශබ්දකෝෂය සංස්කරණය කරන්න", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict පැකේජ .mdx ගොනු භාවිතා කරයි; සහායක .mdd සහ .css ගොනු විකල්පීය වේ.", "Dictionary name": "ශබ්දකෝෂයේ නම", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "මෙම ශබ්දය මෙහි වාදනය කළ නොහැක — ශබ්දකෝෂය යල් පැන ගිය ආකෘතියක් භාවිතා කරයි. Opus, MP3 හෝ WAV ශබ්ද සහිත එකක් උත්සාහ කරන්න." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "මෙම ශබ්දය මෙහි වාදනය කළ නොහැක — ශබ්දකෝෂය යල් පැන ගිය ආකෘතියක් භාවිතා කරයි. Opus, MP3 හෝ WAV ශබ්ද සහිත එකක් උත්සාහ කරන්න.", + "Dictionary uploaded: {{title}}": "ශබ්දකෝෂය උඩුගත කළා: {{title}}", + "Dictionary downloaded: {{title}}": "ශබ්දකෝෂය බාගත කළා: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "ශබ්දකෝෂයේ ක්ලවුඩ් පිටපත මකා දැම්මා: {{title}}", + "Failed to upload dictionary: {{title}}": "ශබ්දකෝෂය උඩුගත කිරීමට අසමත් වුණා: {{title}}", + "Failed to download dictionary: {{title}}": "ශබ්දකෝෂය බාගත කිරීමට අසමත් වුණා: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "ශබ්දකෝෂයේ ක්ලවුඩ් පිටපත මකා දැමීමට අසමත් වුණා: {{title}}" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 9d399f60..edea983b 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1380,5 +1380,11 @@ "Edit Dictionary": "Uredi slovar", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Paketi MDict uporabljajo datoteke .mdx; spremne datoteke .mdd in .css so neobvezne.", "Dictionary name": "Ime slovarja", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tega zvoka tukaj ni mogoče predvajati — slovar uporablja zastarelo obliko. Poskusite slovar z zvokom Opus, MP3 ali WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Tega zvoka tukaj ni mogoče predvajati — slovar uporablja zastarelo obliko. Poskusite slovar z zvokom Opus, MP3 ali WAV.", + "Dictionary uploaded: {{title}}": "Slovar naložen: {{title}}", + "Dictionary downloaded: {{title}}": "Slovar prenesen: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Kopija slovarja v oblaku izbrisana: {{title}}", + "Failed to upload dictionary: {{title}}": "Nalaganje slovarja ni uspelo: {{title}}", + "Failed to download dictionary: {{title}}": "Prenos slovarja ni uspel: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Brisanje kopije slovarja v oblaku ni uspelo: {{title}}" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index e2f356e4..4c6f53c4 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "Redigera ordbok", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict-paket använder .mdx-filer; medföljande .mdd- och .css-filer är valfria.", "Dictionary name": "Ordbokens namn", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Det här ljudet kan inte spelas upp här — ordboken använder ett föråldrat format. Prova en ordbok med Opus-, MP3- eller WAV-ljud." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Det här ljudet kan inte spelas upp här — ordboken använder ett föråldrat format. Prova en ordbok med Opus-, MP3- eller WAV-ljud.", + "Dictionary uploaded: {{title}}": "Ordlista uppladdad: {{title}}", + "Dictionary downloaded: {{title}}": "Ordlista nedladdad: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Molnkopia av ordlistan borttagen: {{title}}", + "Failed to upload dictionary: {{title}}": "Det gick inte att ladda upp ordlistan: {{title}}", + "Failed to download dictionary: {{title}}": "Det gick inte att ladda ned ordlistan: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Det gick inte att ta bort molnkopian av ordlistan: {{title}}" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 14dd59b6..6da2f666 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "அகராதியைத் திருத்து", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict தொகுப்புகள் .mdx கோப்புகளைப் பயன்படுத்துகின்றன; இணைந்த .mdd மற்றும் .css கோப்புகள் விருப்பமானவை.", "Dictionary name": "அகராதியின் பெயர்", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "இந்த ஒலியை இங்கே இயக்க இயலவில்லை — அகராதி காலாவதியான வடிவத்தைப் பயன்படுத்துகிறது. Opus, MP3 அல்லது WAV ஒலி உள்ள ஒன்றை முயற்சிக்கவும்." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "இந்த ஒலியை இங்கே இயக்க இயலவில்லை — அகராதி காலாவதியான வடிவத்தைப் பயன்படுத்துகிறது. Opus, MP3 அல்லது WAV ஒலி உள்ள ஒன்றை முயற்சிக்கவும்.", + "Dictionary uploaded: {{title}}": "அகராதி பதிவேற்றப்பட்டது: {{title}}", + "Dictionary downloaded: {{title}}": "அகராதி பதிவிறக்கப்பட்டது: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "அகராதியின் கிளவுட் நகல் நீக்கப்பட்டது: {{title}}", + "Failed to upload dictionary: {{title}}": "அகராதியை பதிவேற்ற முடியவில்லை: {{title}}", + "Failed to download dictionary: {{title}}": "அகராதியை பதிவிறக்க முடியவில்லை: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "அகராதியின் கிளவுட் நகலை நீக்க முடியவில்லை: {{title}}" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 08f78650..3dfcee0d 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1308,5 +1308,11 @@ "Edit Dictionary": "แก้ไขพจนานุกรม", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "ชุด MDict ใช้ไฟล์ .mdx ส่วนไฟล์ .mdd และ .css ที่มาด้วยกันเป็นทางเลือก", "Dictionary name": "ชื่อพจนานุกรม", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "ไม่สามารถเล่นเสียงนี้ที่นี่ได้ — พจนานุกรมใช้รูปแบบที่ล้าสมัย ลองใช้พจนานุกรมที่มีเสียง Opus, MP3 หรือ WAV" + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "ไม่สามารถเล่นเสียงนี้ที่นี่ได้ — พจนานุกรมใช้รูปแบบที่ล้าสมัย ลองใช้พจนานุกรมที่มีเสียง Opus, MP3 หรือ WAV", + "Dictionary uploaded: {{title}}": "อัปโหลดพจนานุกรมแล้ว: {{title}}", + "Dictionary downloaded: {{title}}": "ดาวน์โหลดพจนานุกรมแล้ว: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "ลบสำเนาคลาวด์ของพจนานุกรมแล้ว: {{title}}", + "Failed to upload dictionary: {{title}}": "อัปโหลดพจนานุกรมไม่สำเร็จ: {{title}}", + "Failed to download dictionary: {{title}}": "ดาวน์โหลดพจนานุกรมไม่สำเร็จ: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "ลบสำเนาคลาวด์ของพจนานุกรมไม่สำเร็จ: {{title}}" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 9509e96a..4df73cbf 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "Sözlüğü düzenle", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict paketleri .mdx dosyalarını kullanır; eşlik eden .mdd ve .css dosyaları isteğe bağlıdır.", "Dictionary name": "Sözlük adı", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu ses burada oynatılamıyor — sözlük eski bir biçim kullanıyor. Opus, MP3 veya WAV sesi olan bir sözlük deneyin." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu ses burada oynatılamıyor — sözlük eski bir biçim kullanıyor. Opus, MP3 veya WAV sesi olan bir sözlük deneyin.", + "Dictionary uploaded: {{title}}": "Sözlük yüklendi: {{title}}", + "Dictionary downloaded: {{title}}": "Sözlük indirildi: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Sözlüğün bulut kopyası silindi: {{title}}", + "Failed to upload dictionary: {{title}}": "Sözlük yüklenemedi: {{title}}", + "Failed to download dictionary: {{title}}": "Sözlük indirilemedi: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Sözlüğün bulut kopyası silinemedi: {{title}}" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index ee464844..c4c879ec 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1380,5 +1380,11 @@ "Edit Dictionary": "Редагувати словник", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Пакунки MDict використовують файли .mdx; супутні файли .mdd і .css є необов’язковими.", "Dictionary name": "Назва словника", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Це аудіо не можна відтворити тут — словник використовує застарілий формат. Спробуйте словник з аудіо Opus, MP3 або WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Це аудіо не можна відтворити тут — словник використовує застарілий формат. Спробуйте словник з аудіо Opus, MP3 або WAV.", + "Dictionary uploaded: {{title}}": "Словник завантажено: {{title}}", + "Dictionary downloaded: {{title}}": "Словник звантажено: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Копію словника у хмарі видалено: {{title}}", + "Failed to upload dictionary: {{title}}": "Не вдалося завантажити словник: {{title}}", + "Failed to download dictionary: {{title}}": "Не вдалося звантажити словник: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Не вдалося видалити копію словника у хмарі: {{title}}" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index a7805dc0..868a7678 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1332,5 +1332,11 @@ "Edit Dictionary": "Lug‘atni tahrirlash", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict to‘plamlari .mdx fayllaridan foydalanadi; hamroh .mdd va .css fayllari ixtiyoriy.", "Dictionary name": "Lug‘at nomi", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu audio bu yerda ijro etilmaydi — lug‘at eskirgan formatdan foydalanadi. Opus, MP3 yoki WAV audioli lug‘atni sinab ko‘ring." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Bu audio bu yerda ijro etilmaydi — lug‘at eskirgan formatdan foydalanadi. Opus, MP3 yoki WAV audioli lug‘atni sinab ko‘ring.", + "Dictionary uploaded: {{title}}": "Lug'at yuklandi: {{title}}", + "Dictionary downloaded: {{title}}": "Lug'at yuklab olindi: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Lug'atning bulutdagi nusxasi o'chirildi: {{title}}", + "Failed to upload dictionary: {{title}}": "Lug'atni yuklab bo'lmadi: {{title}}", + "Failed to download dictionary: {{title}}": "Lug'atni yuklab olib bo'lmadi: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Lug'atning bulutdagi nusxasini o'chirib bo'lmadi: {{title}}" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index ecbd2031..5f3d986c 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1308,5 +1308,11 @@ "Edit Dictionary": "Chỉnh sửa từ điển", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "Gói MDict sử dụng tệp .mdx; các tệp .mdd và .css đi kèm là tùy chọn.", "Dictionary name": "Tên từ điển", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Không thể phát âm thanh này tại đây — từ điển dùng định dạng đã lỗi thời. Hãy thử từ điển có âm thanh Opus, MP3 hoặc WAV." + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "Không thể phát âm thanh này tại đây — từ điển dùng định dạng đã lỗi thời. Hãy thử từ điển có âm thanh Opus, MP3 hoặc WAV.", + "Dictionary uploaded: {{title}}": "Đã tải lên từ điển: {{title}}", + "Dictionary downloaded: {{title}}": "Đã tải xuống từ điển: {{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "Đã xoá bản sao đám mây của từ điển: {{title}}", + "Failed to upload dictionary: {{title}}": "Không thể tải lên từ điển: {{title}}", + "Failed to download dictionary: {{title}}": "Không thể tải xuống từ điển: {{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "Không thể xoá bản sao đám mây của từ điển: {{title}}" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 4c773489..ad3611c3 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1308,5 +1308,11 @@ "Edit Dictionary": "编辑词典", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict 包使用 .mdx 文件;附带的 .mdd 和 .css 文件可选。", "Dictionary name": "词典名称", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "此处无法播放该音频 — 此词典使用过时格式。请尝试使用 Opus、MP3 或 WAV 音频的词典。" + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "此处无法播放该音频 — 此词典使用过时格式。请尝试使用 Opus、MP3 或 WAV 音频的词典。", + "Dictionary uploaded: {{title}}": "词典已上传:{{title}}", + "Dictionary downloaded: {{title}}": "词典已下载:{{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "已删除词典的云端副本:{{title}}", + "Failed to upload dictionary: {{title}}": "词典上传失败:{{title}}", + "Failed to download dictionary: {{title}}": "词典下载失败:{{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "词典云端副本删除失败:{{title}}" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index a4a83ef9..b4410b4a 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1308,5 +1308,11 @@ "Edit Dictionary": "編輯辭典", "MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict 套件使用 .mdx 檔案;附帶的 .mdd 與 .css 檔案為選用。", "Dictionary name": "辭典名稱", - "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "無法在此播放此音訊 — 此辭典使用過時格式。請改用提供 Opus、MP3 或 WAV 音訊的辭典。" + "This audio can't play here — the dictionary uses an outdated format. Try one with Opus, MP3, or WAV audio.": "無法在此播放此音訊 — 此辭典使用過時格式。請改用提供 Opus、MP3 或 WAV 音訊的辭典。", + "Dictionary uploaded: {{title}}": "詞典已上傳:{{title}}", + "Dictionary downloaded: {{title}}": "詞典已下載:{{title}}", + "Deleted cloud copy of the dictionary: {{title}}": "已刪除詞典的雲端副本:{{title}}", + "Failed to upload dictionary: {{title}}": "詞典上傳失敗:{{title}}", + "Failed to download dictionary: {{title}}": "詞典下載失敗:{{title}}", + "Failed to delete cloud copy of the dictionary: {{title}}": "詞典雲端副本刪除失敗:{{title}}" } diff --git a/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx b/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx index ff6e546c..0caa5370 100644 --- a/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx @@ -8,6 +8,10 @@ vi.mock('@/services/environment', async () => { const mockAppService = { init: vi.fn().mockResolvedValue(undefined), + // EnvProvider's mount effect calls appService.loadSettings() to seed + // replica sync. Returning a settings object without replicaDeviceId + // makes init early-exit cleanly (no warn, no real network). + loadSettings: vi.fn().mockResolvedValue({}), // Add any other methods from AppService interface }; diff --git a/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx b/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx index f195ee6f..e4cee73f 100644 --- a/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx @@ -61,7 +61,13 @@ vi.mock('@/services/environment', async (importOriginal) => { : {}), // keep all real default fields API_BASE: 'http://localhost', ENABLE_TRANSLATOR: false, - getAppService: vi.fn().mockResolvedValue(null), + // EnvProvider's mount effect calls appService.loadSettings() to seed + // replica sync. Stubbing with loadSettings returning {} (no + // replicaDeviceId) makes init early-exit cleanly. Returning null + // would crash on `service.loadSettings()` and spam stderr. + getAppService: vi.fn().mockResolvedValue({ + loadSettings: vi.fn().mockResolvedValue({}), + }), }, }; }); diff --git a/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx b/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx new file mode 100644 index 00000000..c1051748 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; + +const pullSpy = vi.fn<(...args: unknown[]) => Promise>(async () => {}); +const getReplicaSyncSpy = vi.fn(); +let envValue: { envConfig: unknown; appService: unknown } = { + envConfig: { name: 'env' }, + appService: null, +}; + +vi.mock('@/services/sync/replicaPullDictionaries', () => ({ + pullDictionariesAndApply: (...args: unknown[]) => pullSpy(...args), +})); + +vi.mock('@/services/sync/replicaSync', () => ({ + getReplicaSync: () => getReplicaSyncSpy(), +})); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => envValue, +})); + +vi.mock('@/services/transferManager', () => ({ + transferManager: { queueReplicaDownload: vi.fn() }, +})); + +vi.mock('@/store/customDictionaryStore', () => ({ + useCustomDictionaryStore: { + getState: () => ({ + applyRemoteDictionary: vi.fn(), + softDeleteByContentId: vi.fn(), + loadCustomDictionaries: vi.fn(async () => {}), + }), + }, + findDictionaryByContentId: () => undefined, +})); + +vi.mock('@/utils/access', () => ({ + getAccessToken: async () => 'token', +})); + +vi.mock('@/utils/misc', () => ({ + uniqueId: () => 'fresh-bundle', +})); + +import { useReplicaPull, __resetReplicaPullForTests } from '@/hooks/useReplicaPull'; + +const fakeService = { createDir: vi.fn(), name: 'fake' }; + +beforeEach(() => { + vi.useFakeTimers(); + pullSpy.mockClear(); + pullSpy.mockResolvedValue(undefined); + getReplicaSyncSpy.mockReset(); + __resetReplicaPullForTests(); + envValue = { envConfig: { name: 'env' }, appService: fakeService }; +}); + +afterEach(() => { + vi.useRealTimers(); + cleanup(); +}); + +describe('useReplicaPull', () => { + test('does not pull before delayMs elapses', () => { + getReplicaSyncSpy.mockReturnValue({ manager: {} }); + renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 5_000 })); + + vi.advanceTimersByTime(4_999); + expect(pullSpy).not.toHaveBeenCalled(); + }); + + test('fires pull after delayMs', async () => { + getReplicaSyncSpy.mockReturnValue({ manager: {} }); + renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 1_000 })); + + await act(async () => { + vi.advanceTimersByTime(1_001); + await Promise.resolve(); + }); + expect(pullSpy).toHaveBeenCalledOnce(); + }); + + test('skips when appService is null', () => { + envValue = { envConfig: { name: 'env' }, appService: null }; + getReplicaSyncSpy.mockReturnValue({ manager: {} }); + renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 })); + + vi.advanceTimersByTime(500); + expect(pullSpy).not.toHaveBeenCalled(); + }); + + test('skips when replica sync context is not initialized', () => { + getReplicaSyncSpy.mockReturnValue(null); + renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 })); + + vi.advanceTimersByTime(500); + expect(pullSpy).not.toHaveBeenCalled(); + }); + + test('only pulls once per kind across multiple mounts', async () => { + getReplicaSyncSpy.mockReturnValue({ manager: {} }); + const first = renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 })); + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + expect(pullSpy).toHaveBeenCalledOnce(); + first.unmount(); + + // Second mount (e.g., navigating to the reader) — same kind should NOT + // re-pull. ReplicaSyncManager.startAutoSync handles visibility / online + // resync; this hook is for the once-per-session initial pull only. + renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 })); + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + expect(pullSpy).toHaveBeenCalledOnce(); + }); + + test('failed pull releases the dedup slot so a later navigation can retry', async () => { + getReplicaSyncSpy.mockReturnValue({ manager: {} }); + pullSpy.mockRejectedValueOnce(new Error('flaky')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const first = renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 })); + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(pullSpy).toHaveBeenCalledTimes(1); + first.unmount(); + + // The slot was released after the rejection — second mount triggers + // a fresh attempt. + renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 })); + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + expect(pullSpy).toHaveBeenCalledTimes(2); + }); + + test('cleanup cancels a pending pull when the component unmounts before delayMs', () => { + getReplicaSyncSpy.mockReturnValue({ manager: {} }); + const view = renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 5_000 })); + vi.advanceTimersByTime(2_000); + view.unmount(); + vi.advanceTimersByTime(10_000); + expect(pullSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/crdt.test.ts b/apps/readest-app/src/__tests__/libs/crdt.test.ts index 164b7527..b3fce4cc 100644 --- a/apps/readest-app/src/__tests__/libs/crdt.test.ts +++ b/apps/readest-app/src/__tests__/libs/crdt.test.ts @@ -230,6 +230,46 @@ describe('removeReplica + mergeReplica (tombstones)', () => { expect(merged.updated_at_ts).toBe(hlc(300)); }); + test('manifest-only merge advances updated_at_ts so pull cursors see it', () => { + const metadata = emptyRow({ + fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A), + manifest_jsonb: null, + updated_at_ts: hlc(100), + }); + const manifest = emptyRow({ + fields_jsonb: {}, + manifest_jsonb: { + schemaVersion: 1, + files: [{ filename: 'foo.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }], + }, + updated_at_ts: hlc(200), + }); + const merged = mergeReplica(metadata, manifest); + expect(merged.fields_jsonb['name']?.v).toBe('Foo'); + expect(merged.manifest_jsonb?.files).toHaveLength(1); + expect(merged.updated_at_ts).toBe(hlc(200)); + }); + + test('metadata-only merge does not clear an existing manifest', () => { + const withManifest = emptyRow({ + fields_jsonb: setField({}, 'name', 'Foo', hlc(100), DEV_A), + manifest_jsonb: { + schemaVersion: 1, + files: [{ filename: 'foo.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }], + }, + updated_at_ts: hlc(200), + }); + const metadataOnly = emptyRow({ + fields_jsonb: setField({}, 'name', 'Renamed', hlc(300), DEV_A), + manifest_jsonb: null, + updated_at_ts: hlc(300), + }); + const merged = mergeReplica(withManifest, metadataOnly); + expect(merged.fields_jsonb['name']?.v).toBe('Renamed'); + expect(merged.manifest_jsonb?.files).toHaveLength(1); + expect(merged.updated_at_ts).toBe(hlc(300)); + }); + test('two tombstones: keep the larger HLC', () => { const a = emptyRow({ deleted_at_ts: hlc(100), updated_at_ts: hlc(100) }); const b = emptyRow({ deleted_at_ts: hlc(200), updated_at_ts: hlc(200) }); @@ -264,4 +304,52 @@ describe('mergeReplica reincarnation interactions', () => { const b = emptyRow({ reincarnation: 'epoch-2', deleted_at_ts: null, updated_at_ts: hlc(200) }); expect(mergeReplica(a, b).reincarnation).toBe('epoch-2'); }); + + test('metadata-only row with null reincarnation does not clear an existing token', () => { + const revived = emptyRow({ + reincarnation: 'epoch-1', + deleted_at_ts: hlc(100), + updated_at_ts: hlc(200), + }); + const rename = emptyRow({ + fields_jsonb: setField({}, 'name', 'Renamed', hlc(300), DEV_A), + reincarnation: null, + deleted_at_ts: null, + updated_at_ts: hlc(300), + }); + const merged = mergeReplica(revived, rename); + expect(merged.fields_jsonb['name']?.v).toBe('Renamed'); + expect(merged.reincarnation).toBe('epoch-1'); + expect(mergeReplica(rename, revived).reincarnation).toBe('epoch-1'); + }); + + test('newer tombstone clears an existing reincarnation token', () => { + const revived = emptyRow({ + reincarnation: 'epoch-1', + deleted_at_ts: hlc(100), + updated_at_ts: hlc(200), + }); + const deleted = emptyRow({ + reincarnation: null, + deleted_at_ts: hlc(300), + updated_at_ts: hlc(300), + }); + const merged = mergeReplica(revived, deleted); + expect(merged.deleted_at_ts).toBe(hlc(300)); + expect(merged.reincarnation).toBe(null); + }); + + test('older duplicate tombstone does not clear a later reincarnation token', () => { + const revived = emptyRow({ + reincarnation: 'epoch-1', + deleted_at_ts: hlc(100), + updated_at_ts: hlc(200), + }); + const duplicateDelete = emptyRow({ + reincarnation: null, + deleted_at_ts: hlc(100), + updated_at_ts: hlc(100), + }); + expect(mergeReplica(revived, duplicateDelete).reincarnation).toBe('epoch-1'); + }); }); diff --git a/apps/readest-app/src/__tests__/libs/replicaInterpret.test.ts b/apps/readest-app/src/__tests__/libs/replicaInterpret.test.ts new file mode 100644 index 00000000..fa85922c --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/replicaInterpret.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'vitest'; +import { isReplicaRowAlive } from '@/libs/replicaInterpret'; +import { hlcPack } from '@/libs/crdt'; +import type { Hlc, ReplicaRow } from '@/types/replica'; + +const NOW = 1_700_000_000_000; +const DEV = 'dev-a'; + +const baseRow = (overrides: Partial = {}): ReplicaRow => ({ + user_id: 'u1', + kind: 'dictionary', + replica_id: 'content-hash-abc', + fields_jsonb: {}, + manifest_jsonb: null, + deleted_at_ts: null, + reincarnation: null, + updated_at_ts: hlcPack(NOW, 0, DEV) as Hlc, + schema_version: 1, + ...overrides, +}); + +describe('isReplicaRowAlive', () => { + test('alive when no tombstone and no reincarnation (fresh row)', () => { + expect(isReplicaRowAlive(baseRow())).toBe(true); + }); + + test('alive when no tombstone (just deleted_at_ts is null)', () => { + expect(isReplicaRowAlive(baseRow({ reincarnation: 'epoch-1' }))).toBe(true); + }); + + test('dead when tombstoned and no reincarnation token (the user just deleted it)', () => { + const tombstone = hlcPack(NOW, 0, DEV) as Hlc; + expect( + isReplicaRowAlive( + baseRow({ + deleted_at_ts: tombstone, + updated_at_ts: tombstone, + reincarnation: null, + }), + ), + ).toBe(false); + }); + + test('alive when reincarnation token is newer than the tombstone', () => { + expect( + isReplicaRowAlive( + baseRow({ + deleted_at_ts: hlcPack(NOW, 0, DEV) as Hlc, + reincarnation: 'epoch-1', + updated_at_ts: hlcPack(NOW + 1000, 0, DEV) as Hlc, + }), + ), + ).toBe(true); + }); + + test('dead when tombstone is newer than the reincarnation (deleted again after revival)', () => { + expect( + isReplicaRowAlive( + baseRow({ + deleted_at_ts: hlcPack(NOW + 5000, 0, DEV) as Hlc, + reincarnation: 'epoch-1', + updated_at_ts: hlcPack(NOW + 1000, 0, DEV) as Hlc, + }), + ), + ).toBe(false); + }); + + test('alive when reincarnation == tombstone HLC (edge of order)', () => { + const t = hlcPack(NOW + 1000, 0, DEV) as Hlc; + expect( + isReplicaRowAlive( + baseRow({ + deleted_at_ts: t, + reincarnation: 'epoch-1', + updated_at_ts: t, + }), + ), + ).toBe(true); + }); + + test('dead when reincarnation is set but tombstone is null is impossible — but tolerate gracefully', () => { + expect(isReplicaRowAlive(baseRow({ deleted_at_ts: null, reincarnation: 'epoch-1' }))).toBe( + true, + ); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/dictionaryDedup.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/dictionaryDedup.test.ts new file mode 100644 index 00000000..a0053143 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/dictionaryDedup.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, test } from 'vitest'; +import { + findExistingDictionaryMatches, + findTombstonedDictionaryMatches, + preserveLiveDictionaryState, + preserveUserCustomName, + shouldMintReincarnationForLiveReimport, +} from '@/services/dictionaries/dictionaryDedup'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; + +const baseDict = (overrides: Partial = {}): ImportedDictionary => ({ + id: 'bundle-1', + contentId: 'content-hash-A', + kind: 'mdict', + name: 'Webster Original', + bundleDir: 'bundle-1', + files: { mdx: 'webster.mdx' }, + addedAt: 0, + ...overrides, +}); + +describe('findExistingDictionaryMatches', () => { + test('matches by contentId when both incoming and existing have it', () => { + const existing = [baseDict({ id: 'old', name: 'Webster Original' })]; + const incoming = baseDict({ id: 'new', name: 'Webster Original' }); + expect(findExistingDictionaryMatches(incoming, existing)).toEqual(existing); + }); + + test('matches by contentId even when the user has renamed the existing entry', () => { + // Bug repro: user renamed the dict. Re-importing the same file produces + // a fresh dict whose .name matches the bundle's parsed Title (the + // ORIGINAL name), not the user's new label. ContentId-based match catches + // it; name-based match would not. + const renamed = baseDict({ + id: 'old', + contentId: 'content-hash-A', + name: 'My Renamed Dict', + }); + const incoming = baseDict({ + id: 'new', + contentId: 'content-hash-A', + name: 'Webster Original', + }); + expect(findExistingDictionaryMatches(incoming, [renamed])).toEqual([renamed]); + }); + + test('contentId mismatch with name match: still matches by name (legacy fallback)', () => { + // Legacy bundle: existing dict has no contentId; incoming has one. + // The legacy entry's name is the only signal we have to dedup. + const legacy = baseDict({ + id: 'old', + contentId: undefined, + name: 'Webster Original', + }); + const incoming = baseDict({ + id: 'new', + contentId: 'content-hash-A', + name: 'Webster Original', + }); + expect(findExistingDictionaryMatches(incoming, [legacy])).toEqual([legacy]); + }); + + test('different contentId + same name does NOT match (different file with same Title)', () => { + const existing = baseDict({ id: 'old', contentId: 'content-hash-A', name: 'Webster' }); + const incoming = baseDict({ id: 'new', contentId: 'content-hash-B', name: 'Webster' }); + expect(findExistingDictionaryMatches(incoming, [existing])).toEqual([]); + }); + + test('soft-deleted entries are not considered matches', () => { + const deleted = baseDict({ id: 'old', deletedAt: Date.now() }); + const incoming = baseDict({ id: 'new' }); + expect(findExistingDictionaryMatches(incoming, [deleted])).toEqual([]); + }); + + test('returns all existing entries with the same contentId (multi-import history)', () => { + const a = baseDict({ id: 'old-1', contentId: 'content-hash-A' }); + const b = baseDict({ id: 'old-2', contentId: 'content-hash-A' }); + const incoming = baseDict({ id: 'new', contentId: 'content-hash-A' }); + expect(findExistingDictionaryMatches(incoming, [a, b])).toEqual([a, b]); + }); + + test('returns all existing entries with the same name (multi-import legacy)', () => { + const a = baseDict({ id: 'old-1', contentId: undefined, name: 'Webster' }); + const b = baseDict({ id: 'old-2', contentId: undefined, name: 'Webster' }); + const incoming = baseDict({ id: 'new', contentId: 'content-hash-A', name: 'Webster' }); + expect(findExistingDictionaryMatches(incoming, [a, b])).toEqual([a, b]); + }); + + test('incoming without contentId only matches existing entries that also lack contentId', () => { + // Asymmetry: an existing entry with contentId is "tier-1 identity" — we + // know its content hash. An incoming bundle without contentId is "tier-0". + // Matching across tiers risks false positives (different file with same + // Title), so we match only within the legacy/legacy bucket. New imports + // always carry contentId so this only fires for synthetic call-sites. + const tier1 = baseDict({ id: 'old-tier1', contentId: 'A', name: 'Webster' }); + const tier0 = baseDict({ id: 'old-tier0', contentId: undefined, name: 'Webster' }); + const incoming = baseDict({ id: 'new', contentId: undefined, name: 'Webster' }); + expect(findExistingDictionaryMatches(incoming, [tier1, tier0])).toEqual([tier0]); + }); + + test('no matches when nothing aligns', () => { + const existing = baseDict({ id: 'old', contentId: 'A', name: 'Foo' }); + const incoming = baseDict({ id: 'new', contentId: 'B', name: 'Bar' }); + expect(findExistingDictionaryMatches(incoming, [existing])).toEqual([]); + }); +}); + +describe('preserveUserCustomName', () => { + test("returns the new dict with the existing entry's name", () => { + const existing = baseDict({ id: 'old', name: 'My Renamed Dict' }); + const incoming = baseDict({ id: 'new', name: 'Webster Original' }); + expect(preserveUserCustomName(incoming, [existing]).name).toBe('My Renamed Dict'); + }); + + test("uses the FIRST matched entry's name when multiple exist", () => { + const a = baseDict({ id: 'old-1', name: "Alice's label" }); + const b = baseDict({ id: 'old-2', name: "Bob's label" }); + const incoming = baseDict({ id: 'new', name: 'Original' }); + expect(preserveUserCustomName(incoming, [a, b]).name).toBe("Alice's label"); + }); + + test('returns the new dict unchanged when matches array is empty', () => { + const incoming = baseDict({ id: 'new', name: 'Original' }); + expect(preserveUserCustomName(incoming, []).name).toBe('Original'); + }); + + test('returns a new object (does not mutate the incoming dict)', () => { + const existing = baseDict({ name: 'Renamed' }); + const incoming = baseDict({ name: 'Original' }); + const result = preserveUserCustomName(incoming, [existing]); + expect(result).not.toBe(incoming); + expect(incoming.name).toBe('Original'); + }); +}); + +describe('findTombstonedDictionaryMatches', () => { + test('matches soft-deleted entries with the same contentId', () => { + const tombstoned = baseDict({ + id: 'old', + contentId: 'content-hash-A', + deletedAt: 1700000000000, + }); + const incoming = baseDict({ id: 'new', contentId: 'content-hash-A' }); + expect(findTombstonedDictionaryMatches(incoming, [tombstoned])).toEqual([tombstoned]); + }); + + test('does NOT match live entries (those go through the live-replacement path)', () => { + const live = baseDict({ id: 'old', contentId: 'A', deletedAt: undefined }); + const incoming = baseDict({ id: 'new', contentId: 'A' }); + expect(findTombstonedDictionaryMatches(incoming, [live])).toEqual([]); + }); + + test('does NOT match when contentIds differ', () => { + const tombstoned = baseDict({ id: 'old', contentId: 'A', deletedAt: 1 }); + const incoming = baseDict({ id: 'new', contentId: 'B' }); + expect(findTombstonedDictionaryMatches(incoming, [tombstoned])).toEqual([]); + }); + + test('returns [] when incoming has no contentId (legacy bundles cannot reincarnate)', () => { + const tombstoned = baseDict({ id: 'old', contentId: 'A', deletedAt: 1 }); + const incoming = baseDict({ id: 'new', contentId: undefined }); + expect(findTombstonedDictionaryMatches(incoming, [tombstoned])).toEqual([]); + }); + + test('returns all tombstoned entries with the same contentId (multi-history)', () => { + const a = baseDict({ id: 'old-1', contentId: 'A', deletedAt: 1 }); + const b = baseDict({ id: 'old-2', contentId: 'A', deletedAt: 2 }); + const incoming = baseDict({ id: 'new', contentId: 'A' }); + expect(findTombstonedDictionaryMatches(incoming, [a, b])).toEqual([a, b]); + }); + + test('does NOT match tombstoned entries that lack contentId (legacy)', () => { + const tombstoned = baseDict({ id: 'old', contentId: undefined, deletedAt: 1 }); + const incoming = baseDict({ id: 'new', contentId: 'A' }); + expect(findTombstonedDictionaryMatches(incoming, [tombstoned])).toEqual([]); + }); +}); + +describe('preserveLiveDictionaryState', () => { + test('carries the existing entry name, addedAt, and reincarnation token onto incoming', () => { + const existing = baseDict({ + id: 'old', + name: 'User Label', + addedAt: 123, + reincarnation: 'epoch-1', + }); + const incoming = baseDict({ + id: 'new', + name: 'Parsed Label', + addedAt: 456, + reincarnation: undefined, + }); + const result = preserveLiveDictionaryState(incoming, [existing]); + expect(result.name).toBe('User Label'); + expect(result.addedAt).toBe(123); + expect(result.reincarnation).toBe('epoch-1'); + }); + + test('keeps file-backed fields from the incoming bundle', () => { + const existing = baseDict({ + id: 'old', + contentId: 'old-content', + bundleDir: 'old-dir', + files: { mdx: 'old.mdx' }, + lang: 'en', + unsupported: true, + unsupportedReason: 'old parser failure', + unavailable: true, + }); + const incoming = baseDict({ + id: 'new', + contentId: 'new-content', + bundleDir: 'new-dir', + files: { mdx: 'new.mdx' }, + lang: 'gbk', + unsupported: undefined, + unsupportedReason: undefined, + unavailable: undefined, + }); + const result = preserveLiveDictionaryState(incoming, [existing]); + expect(result.id).toBe('new'); + expect(result.contentId).toBe('new-content'); + expect(result.bundleDir).toBe('new-dir'); + expect(result.files).toEqual({ mdx: 'new.mdx' }); + expect(result.lang).toBe('gbk'); + expect(result.unsupported).toBeUndefined(); + expect(result.unsupportedReason).toBeUndefined(); + expect(result.unavailable).toBeUndefined(); + }); + + test("uses the FIRST matched entry's live state when multiple exist", () => { + const a = baseDict({ id: 'old-1', name: 'A', addedAt: 1, reincarnation: 'epoch-A' }); + const b = baseDict({ id: 'old-2', name: 'B', addedAt: 2, reincarnation: 'epoch-B' }); + const incoming = baseDict({ id: 'new', name: 'Parsed', addedAt: 3 }); + const result = preserveLiveDictionaryState(incoming, [a, b]); + expect(result.name).toBe('A'); + expect(result.addedAt).toBe(1); + expect(result.reincarnation).toBe('epoch-A'); + }); + + test('no-op when matches is empty', () => { + const incoming = baseDict({ id: 'new', name: 'Parsed', addedAt: 3 }); + expect(preserveLiveDictionaryState(incoming, [])).toEqual(incoming); + }); + + test('does NOT mutate incoming', () => { + const existing = baseDict({ id: 'old', name: 'User Label', reincarnation: 'epoch-1' }); + const incoming = baseDict({ id: 'new', name: 'Parsed' }); + const result = preserveLiveDictionaryState(incoming, [existing]); + expect(result).not.toBe(incoming); + expect(incoming.name).toBe('Parsed'); + expect(incoming.reincarnation).toBeUndefined(); + }); + + test('preserves an explicit incoming reincarnation when matches has none', () => { + const existing = baseDict({ id: 'old', reincarnation: undefined }); + const incoming = baseDict({ id: 'new', reincarnation: 'fresh-mint' }); + expect(preserveLiveDictionaryState(incoming, [existing]).reincarnation).toBe('fresh-mint'); + }); +}); + +describe('shouldMintReincarnationForLiveReimport', () => { + test('mints when explicit live re-import matches the same content and has no token', () => { + const existing = baseDict({ id: 'old', contentId: 'content-hash-A' }); + const incoming = baseDict({ id: 'new', contentId: 'content-hash-A' }); + expect(shouldMintReincarnationForLiveReimport(incoming, [existing])).toBe(true); + }); + + test('does not mint when the live entry already has a token to preserve', () => { + const existing = baseDict({ + id: 'old', + contentId: 'content-hash-A', + reincarnation: 'epoch-1', + }); + const incoming = baseDict({ id: 'new', contentId: 'content-hash-A' }); + expect(shouldMintReincarnationForLiveReimport(incoming, [existing])).toBe(false); + }); + + test('does not mint for legacy name-only replacements', () => { + const existing = baseDict({ id: 'old', contentId: undefined, name: 'Webster' }); + const incoming = baseDict({ id: 'new', contentId: 'content-hash-A', name: 'Webster' }); + expect(shouldMintReincarnationForLiveReimport(incoming, [existing])).toBe(false); + }); + + test('does not mint without matches or without incoming contentId', () => { + expect(shouldMintReincarnationForLiveReimport(baseDict(), [])).toBe(false); + expect( + shouldMintReincarnationForLiveReimport(baseDict({ contentId: undefined }), [baseDict()]), + ).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaBinaryUpload.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaBinaryUpload.test.ts new file mode 100644 index 00000000..dae163a5 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/replicaBinaryUpload.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@/services/transferManager', () => ({ + transferManager: { + isReady: vi.fn(), + queueReplicaUpload: vi.fn(), + }, +})); + +import { transferManager } from '@/services/transferManager'; +import { queueDictionaryBinaryUpload } from '@/services/sync/replicaBinaryUpload'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; +import type { AppService } from '@/types/system'; + +const mockIsReady = transferManager.isReady as ReturnType; +const mockQueueReplicaUpload = transferManager.queueReplicaUpload as ReturnType; + +const makeFakeAppService = (sizes: Record) => ({ + openFile: vi.fn(async (path: string) => ({ + size: sizes[path] ?? 0, + name: path, + })), +}); + +const baseDict = (overrides: Partial = {}): ImportedDictionary => ({ + id: 'bundle-id', + contentId: 'content-hash-abc', + kind: 'mdict', + name: 'Webster', + bundleDir: 'bundle-dir', + files: { mdx: 'webster.mdx', mdd: ['webster.mdd'] }, + addedAt: 0, + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('queueDictionaryBinaryUpload', () => { + test('no-ops when contentId is missing (legacy bundle)', async () => { + mockIsReady.mockReturnValue(true); + const fakeAppService = makeFakeAppService({}) as unknown as AppService; + const result = await queueDictionaryBinaryUpload( + baseDict({ contentId: undefined }), + fakeAppService, + ); + expect(result).toBe(null); + expect(mockQueueReplicaUpload).not.toHaveBeenCalled(); + }); + + test('no-ops when TransferManager is not initialized', async () => { + mockIsReady.mockReturnValue(false); + const fakeAppService = makeFakeAppService({}) as unknown as AppService; + const result = await queueDictionaryBinaryUpload(baseDict(), fakeAppService); + expect(result).toBe(null); + expect(mockQueueReplicaUpload).not.toHaveBeenCalled(); + }); + + test('queues upload with file sizes resolved via fs', async () => { + mockIsReady.mockReturnValue(true); + mockQueueReplicaUpload.mockReturnValue('transfer-id-1'); + const fakeAppService = makeFakeAppService({ + 'bundle-dir/webster.mdx': 1_000_000, + 'bundle-dir/webster.mdd': 5_000_000, + }) as unknown as AppService; + + const result = await queueDictionaryBinaryUpload(baseDict(), fakeAppService); + + expect(result).toBe('transfer-id-1'); + expect(mockQueueReplicaUpload).toHaveBeenCalledOnce(); + const [kind, contentId, displayTitle, files, base, opts] = + mockQueueReplicaUpload.mock.calls[0]!; + expect(kind).toBe('dictionary'); + expect(contentId).toBe('content-hash-abc'); + expect(displayTitle).toBe('Webster'); + expect(files).toEqual([ + { logical: 'webster.mdx', lfp: 'bundle-dir/webster.mdx', byteSize: 1_000_000 }, + { logical: 'webster.mdd', lfp: 'bundle-dir/webster.mdd', byteSize: 5_000_000 }, + ]); + expect(base).toBe('Dictionaries'); + expect(opts).toEqual({ reincarnation: undefined }); + }); + + test('passes reincarnation token through to the replica transfer', async () => { + mockIsReady.mockReturnValue(true); + mockQueueReplicaUpload.mockReturnValue('transfer-id-1'); + const fakeAppService = makeFakeAppService({ + 'bundle-dir/webster.mdx': 1_000_000, + 'bundle-dir/webster.mdd': 5_000_000, + }) as unknown as AppService; + + await queueDictionaryBinaryUpload(baseDict({ reincarnation: 'epoch-1' }), fakeAppService); + + expect(mockQueueReplicaUpload.mock.calls[0]![5]).toEqual({ reincarnation: 'epoch-1' }); + }); + + test('returns null when bundle has no enumerable files', async () => { + mockIsReady.mockReturnValue(true); + const fakeAppService = makeFakeAppService({}) as unknown as AppService; + const result = await queueDictionaryBinaryUpload( + baseDict({ kind: 'mdict', files: {} }), + fakeAppService, + ); + expect(result).toBe(null); + expect(mockQueueReplicaUpload).not.toHaveBeenCalled(); + }); + + test('handles stardict bundle with all four files', async () => { + mockIsReady.mockReturnValue(true); + mockQueueReplicaUpload.mockReturnValue('t-2'); + const dict = baseDict({ + kind: 'stardict', + files: { + ifo: 'cmu.ifo', + idx: 'cmu.idx', + dict: 'cmu.dict.dz', + syn: 'cmu.syn', + idxOffsets: 'cmu.idx.offsets', + }, + }); + const fakeAppService = makeFakeAppService({ + 'bundle-dir/cmu.ifo': 100, + 'bundle-dir/cmu.idx': 200, + 'bundle-dir/cmu.dict.dz': 300, + 'bundle-dir/cmu.syn': 400, + }) as unknown as AppService; + + await queueDictionaryBinaryUpload(dict, fakeAppService); + + const files = mockQueueReplicaUpload.mock.calls[0]![3]; + expect(files.map((f: { logical: string }) => f.logical)).toEqual([ + 'cmu.ifo', + 'cmu.idx', + 'cmu.dict.dz', + 'cmu.syn', + ]); + }); + + test('closes opened files (matches getBookFileSize pattern)', async () => { + mockIsReady.mockReturnValue(true); + mockQueueReplicaUpload.mockReturnValue('t-3'); + const close = vi.fn(); + const fakeAppService = { + openFile: vi.fn(async () => ({ size: 100, close })), + }; + await queueDictionaryBinaryUpload(baseDict(), fakeAppService as unknown as AppService); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaCursorStore.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaCursorStore.test.ts new file mode 100644 index 00000000..f24db5da --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/replicaCursorStore.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore'; +import type { AppService } from '@/types/system'; +import type { Hlc } from '@/types/replica'; +import type { SystemSettings } from '@/types/settings'; + +const makeFakeAppService = (initial: Partial = {}) => { + let settings = { ...initial } as SystemSettings; + return { + loadSettings: vi.fn(async () => settings), + saveSettings: vi.fn(async (s: SystemSettings) => { + settings = { ...s }; + }), + getSettings: () => settings, + }; +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('createSettingsCursorStore', () => { + test('hydrates cache from settings.lastSyncedAtReplicas on init', async () => { + const fake = makeFakeAppService({ + lastSyncedAtReplicas: { dictionary: 'cur-dict', font: 'cur-font' }, + }); + const store = createSettingsCursorStore(fake as unknown as AppService); + await vi.advanceTimersByTimeAsync(0); + expect(store.get('dictionary')).toBe('cur-dict'); + expect(store.get('font')).toBe('cur-font'); + }); + + test('get returns null when cursor not in settings', async () => { + const fake = makeFakeAppService(); + const store = createSettingsCursorStore(fake as unknown as AppService); + await vi.advanceTimersByTimeAsync(0); + expect(store.get('dictionary')).toBe(null); + }); + + test('set updates the cache synchronously', () => { + const fake = makeFakeAppService(); + const store = createSettingsCursorStore(fake as unknown as AppService); + store.set('dictionary', 'cur-1' as Hlc); + expect(store.get('dictionary')).toBe('cur-1'); + }); + + test('set debounces a save flush; no save fires immediately', () => { + const fake = makeFakeAppService({ replicaDeviceId: 'dev-a' }); + const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 1000 }); + store.set('dictionary', 'cur-1' as Hlc); + expect(fake.saveSettings).not.toHaveBeenCalled(); + }); + + test('save fires after debounceMs', async () => { + const fake = makeFakeAppService({ replicaDeviceId: 'dev-a' }); + const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 1000 }); + store.set('dictionary', 'cur-1' as Hlc); + await vi.advanceTimersByTimeAsync(999); + expect(fake.saveSettings).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2); + expect(fake.saveSettings).toHaveBeenCalledOnce(); + const saved = fake.saveSettings.mock.calls[0]![0]; + expect(saved.lastSyncedAtReplicas).toEqual({ dictionary: 'cur-1' }); + }); + + test('successive sets within debounce window collapse to one save', async () => { + const fake = makeFakeAppService(); + const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 1000 }); + store.set('dictionary', 'a' as Hlc); + await vi.advanceTimersByTimeAsync(500); + store.set('dictionary', 'b' as Hlc); + await vi.advanceTimersByTimeAsync(500); + store.set('dictionary', 'c' as Hlc); + await vi.advanceTimersByTimeAsync(1100); + expect(fake.saveSettings).toHaveBeenCalledOnce(); + expect(fake.saveSettings.mock.calls[0]![0].lastSyncedAtReplicas).toEqual({ dictionary: 'c' }); + }); + + test('save preserves other settings fields (load-merge-save round-trip)', async () => { + const fake = makeFakeAppService({ + replicaDeviceId: 'dev-a', + keepLogin: true, + } as Partial); + const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 100 }); + await vi.advanceTimersByTimeAsync(0); + store.set('dictionary', 'cur' as Hlc); + await vi.advanceTimersByTimeAsync(110); + const saved = fake.saveSettings.mock.calls[0]![0]; + expect(saved.replicaDeviceId).toBe('dev-a'); + expect(saved.keepLogin).toBe(true); + expect(saved.lastSyncedAtReplicas).toEqual({ dictionary: 'cur' }); + }); + + test('save preserves cursors for other kinds', async () => { + const fake = makeFakeAppService({ + lastSyncedAtReplicas: { font: 'cur-font' }, + }); + const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 100 }); + await vi.advanceTimersByTimeAsync(0); + store.set('dictionary', 'cur-dict' as Hlc); + await vi.advanceTimersByTimeAsync(110); + const saved = fake.saveSettings.mock.calls[0]![0]; + expect(saved.lastSyncedAtReplicas).toEqual({ font: 'cur-font', dictionary: 'cur-dict' }); + }); + + test('save error does not throw to caller (best-effort)', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fake = makeFakeAppService(); + fake.saveSettings.mockRejectedValueOnce(new Error('disk full')); + const store = createSettingsCursorStore(fake as unknown as AppService, { debounceMs: 100 }); + await vi.advanceTimersByTimeAsync(0); + expect(() => store.set('dictionary', 'cur' as Hlc)).not.toThrow(); + await vi.advanceTimersByTimeAsync(110); + await Promise.resolve(); + }); + + test('hydrate failure does not break subsequent get/set', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fake = makeFakeAppService(); + fake.loadSettings.mockRejectedValueOnce(new Error('parse error')); + const store = createSettingsCursorStore(fake as unknown as AppService); + await vi.advanceTimersByTimeAsync(0); + expect(store.get('dictionary')).toBe(null); + store.set('dictionary', 'cur' as Hlc); + expect(store.get('dictionary')).toBe('cur'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaDictionaryApply.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaDictionaryApply.test.ts new file mode 100644 index 00000000..a67d89db --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/replicaDictionaryApply.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from 'vitest'; +import { + buildLocalDictFromRow, + filesFromManifest, + unwrapDictionaryFields, +} from '@/services/sync/replicaDictionaryApply'; +import { hlcPack } from '@/libs/crdt'; +import type { Hlc, Manifest, ReplicaRow } from '@/types/replica'; + +const NOW = 1_700_000_000_000; +const DEV = 'dev-a'; + +const baseRow = (overrides: Partial = {}): ReplicaRow => ({ + user_id: 'u1', + kind: 'dictionary', + replica_id: 'content-hash-abc', + fields_jsonb: { + name: { v: 'Webster', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV }, + kind: { v: 'mdict', t: hlcPack(NOW, 1, DEV) as Hlc, s: DEV }, + lang: { v: 'en', t: hlcPack(NOW, 2, DEV) as Hlc, s: DEV }, + addedAt: { v: 1700000000000, t: hlcPack(NOW, 3, DEV) as Hlc, s: DEV }, + }, + manifest_jsonb: null, + deleted_at_ts: null, + reincarnation: null, + updated_at_ts: hlcPack(NOW, 3, DEV) as Hlc, + schema_version: 1, + ...overrides, +}); + +const manifest = ( + files: { filename: string; byteSize?: number; partialMd5?: string }[], +): Manifest => ({ + files: files.map((f) => ({ + filename: f.filename, + byteSize: f.byteSize ?? 0, + partialMd5: f.partialMd5 ?? '', + })), + schemaVersion: 1, +}); + +describe('unwrapDictionaryFields', () => { + test('extracts plain values from envelope fields', () => { + const row = baseRow(); + expect(unwrapDictionaryFields(row.fields_jsonb)).toEqual({ + name: 'Webster', + kind: 'mdict', + lang: 'en', + addedAt: 1700000000000, + }); + }); + + test('returns undefined for missing fields', () => { + const row = baseRow(); + delete (row.fields_jsonb as Record)['lang']; + expect(unwrapDictionaryFields(row.fields_jsonb).lang).toBeUndefined(); + }); + + test('handles unsupportedReason if present', () => { + const row = baseRow(); + row.fields_jsonb['unsupportedReason'] = { + v: 'encrypted', + t: hlcPack(NOW, 4, DEV) as Hlc, + s: DEV, + }; + row.fields_jsonb['unsupported'] = { v: true, t: hlcPack(NOW, 5, DEV) as Hlc, s: DEV }; + const fields = unwrapDictionaryFields(row.fields_jsonb); + expect(fields.unsupported).toBe(true); + expect(fields.unsupportedReason).toBe('encrypted'); + }); +}); + +describe('filesFromManifest', () => { + test('mdict manifest: classifies mdx + mdd + css', () => { + const m = manifest([ + { filename: 'webster.mdx' }, + { filename: 'webster.mdd' }, + { filename: 'webster.1.mdd' }, + { filename: 'webster.css' }, + ]); + expect(filesFromManifest(m, 'mdict')).toEqual({ + mdx: 'webster.mdx', + mdd: ['webster.mdd', 'webster.1.mdd'], + css: ['webster.css'], + }); + }); + + test('stardict manifest: classifies ifo + idx + dict + syn (skips offsets)', () => { + const m = manifest([ + { filename: 'd.ifo' }, + { filename: 'd.idx' }, + { filename: 'd.dict.dz' }, + { filename: 'd.syn' }, + ]); + expect(filesFromManifest(m, 'stardict')).toEqual({ + ifo: 'd.ifo', + idx: 'd.idx', + dict: 'd.dict.dz', + syn: 'd.syn', + }); + }); + + test('dict manifest: classifies dict + index', () => { + const m = manifest([{ filename: 'w.dict.dz' }, { filename: 'w.index' }]); + expect(filesFromManifest(m, 'dict')).toEqual({ + dict: 'w.dict.dz', + index: 'w.index', + }); + }); + + test('slob manifest: classifies single .slob', () => { + const m = manifest([{ filename: 'w.slob' }]); + expect(filesFromManifest(m, 'slob')).toEqual({ slob: 'w.slob' }); + }); + + test('null manifest returns empty files object', () => { + expect(filesFromManifest(null, 'mdict')).toEqual({}); + }); + + test('empty manifest returns empty files object', () => { + expect(filesFromManifest(manifest([]), 'mdict')).toEqual({}); + }); +}); + +describe('buildLocalDictFromRow', () => { + test('builds a complete ImportedDictionary from a row + bundleDir', () => { + const row = baseRow({ + manifest_jsonb: manifest([ + { filename: 'webster.mdx', byteSize: 1000 }, + { filename: 'webster.mdd', byteSize: 5000 }, + ]), + }); + const dict = buildLocalDictFromRow(row, 'local-bundle-1'); + expect(dict).not.toBe(null); + expect(dict!.id).toBe('local-bundle-1'); + expect(dict!.contentId).toBe('content-hash-abc'); + expect(dict!.kind).toBe('mdict'); + expect(dict!.name).toBe('Webster'); + expect(dict!.lang).toBe('en'); + expect(dict!.addedAt).toBe(1700000000000); + expect(dict!.bundleDir).toBe('local-bundle-1'); + expect(dict!.files).toEqual({ mdx: 'webster.mdx', mdd: ['webster.mdd'] }); + expect(dict!.unavailable).toBe(true); + }); + + test('null manifest produces an empty files object (still unavailable)', () => { + const row = baseRow({ manifest_jsonb: null }); + const dict = buildLocalDictFromRow(row, 'b'); + expect(dict!.files).toEqual({}); + expect(dict!.unavailable).toBe(true); + }); + + test('returns null when fields are malformed (missing kind)', () => { + const row = baseRow(); + delete (row.fields_jsonb as Record)['kind']; + expect(buildLocalDictFromRow(row, 'b')).toBe(null); + }); + + test('returns null when fields are malformed (missing name)', () => { + const row = baseRow(); + delete (row.fields_jsonb as Record)['name']; + expect(buildLocalDictFromRow(row, 'b')).toBe(null); + }); + + test('propagates reincarnation token from the row to the dict', () => { + const row = baseRow({ reincarnation: 'epoch-1' }); + expect(buildLocalDictFromRow(row, 'b')!.reincarnation).toBe('epoch-1'); + }); + + test('propagates unsupported flags', () => { + const row = baseRow(); + row.fields_jsonb['unsupported'] = { v: true, t: hlcPack(NOW, 4, DEV) as Hlc, s: DEV }; + row.fields_jsonb['unsupportedReason'] = { + v: 'encrypted MDX', + t: hlcPack(NOW, 5, DEV) as Hlc, + s: DEV, + }; + const dict = buildLocalDictFromRow(row, 'b')!; + expect(dict.unsupported).toBe(true); + expect(dict.unsupportedReason).toBe('encrypted MDX'); + }); + + test('falls back to current time when addedAt missing', () => { + const row = baseRow(); + delete (row.fields_jsonb as Record)['addedAt']; + const before = Date.now(); + const dict = buildLocalDictFromRow(row, 'b')!; + const after = Date.now(); + expect(dict.addedAt).toBeGreaterThanOrEqual(before); + expect(dict.addedAt).toBeLessThanOrEqual(after); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaPublish.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaPublish.test.ts new file mode 100644 index 00000000..e92e5776 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/replicaPublish.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@/utils/access', () => ({ + getUserID: vi.fn(), +})); + +vi.mock('@/services/sync/replicaSync', () => ({ + getReplicaSync: vi.fn(), +})); + +import { getUserID } from '@/utils/access'; +import { getReplicaSync } from '@/services/sync/replicaSync'; +import { + publishDictionaryDelete, + publishDictionaryManifest, + publishDictionaryUpsert, +} from '@/services/sync/replicaPublish'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; +import { HlcGenerator, hlcPack } from '@/libs/crdt'; +import type { Hlc, ReplicaRow } from '@/types/replica'; + +const NOW = 1_700_000_000_000; +const DEV = 'dev-a'; + +const baseDict = (overrides: Partial = {}): ImportedDictionary => ({ + id: 'bundle-dir-xyz', + contentId: 'content-hash-abc', + kind: 'mdict', + name: 'Webster', + bundleDir: 'bundle-dir-xyz', + files: { mdx: 'webster.mdx' }, + addedAt: NOW, + ...overrides, +}); + +const makeFakeCtx = () => { + const hlc = new HlcGenerator(DEV, () => NOW); + const manager = { + markDirty: vi.fn(), + flush: vi.fn(), + pull: vi.fn(), + startAutoSync: vi.fn(), + stopAutoSync: vi.fn(), + pendingCount: vi.fn(() => 0), + pendingKeys: vi.fn(() => []), + }; + return { manager, hlc, deviceId: DEV }; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('publishDictionaryUpsert', () => { + test('no-ops when replicaSync is not initialized', async () => { + (getReplicaSync as ReturnType).mockReturnValue(null); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryUpsert(baseDict()); + }); + + test('no-ops when contentId is absent (legacy bundle)', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryUpsert(baseDict({ contentId: undefined })); + expect(ctx.manager.markDirty).not.toHaveBeenCalled(); + }); + + test('no-ops when user not authenticated', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue(null); + await publishDictionaryUpsert(baseDict()); + expect(ctx.manager.markDirty).not.toHaveBeenCalled(); + }); + + test('builds + markDirty a kind=dictionary row keyed by contentId', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + const dict = baseDict({ name: 'Webster Concise', lang: 'en' }); + await publishDictionaryUpsert(dict); + + expect(ctx.manager.markDirty).toHaveBeenCalledOnce(); + const row = ctx.manager.markDirty.mock.calls[0]![0]; + expect(row.user_id).toBe('user-1'); + expect(row.kind).toBe('dictionary'); + expect(row.replica_id).toBe('content-hash-abc'); + expect(row.fields_jsonb['name']?.v).toBe('Webster Concise'); + expect(row.fields_jsonb['lang']?.v).toBe('en'); + expect(row.fields_jsonb['kind']?.v).toBe('mdict'); + expect(row.deleted_at_ts).toBe(null); + expect(row.schema_version).toBe(1); + }); + + test('every field gets a fresh HLC stamp + deviceId', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryUpsert(baseDict()); + const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow; + for (const env of Object.values(row.fields_jsonb)) { + expect(env.s).toBe(DEV); + expect(env.t).toMatch(/^[0-9a-f]+-[0-9a-f]+-dev-a$/); + } + }); + + test('updated_at_ts is the maximum of all field HLCs', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryUpsert(baseDict()); + const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow; + const fieldHlcs = Object.values(row.fields_jsonb).map((e) => e.t); + const maxField = fieldHlcs.reduce((a, b) => (a > b ? a : b)); + expect(row.updated_at_ts >= maxField).toBe(true); + }); + + test('reincarnation field on the dict propagates to the row (revives a tombstoned row)', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryUpsert(baseDict({ reincarnation: 'epoch-1' })); + const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow; + expect(row.reincarnation).toBe('epoch-1'); + }); + + test('reincarnation defaults to null when absent on the dict (first import or live re-import)', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryUpsert(baseDict()); + const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow; + expect(row.reincarnation).toBe(null); + }); +}); + +describe('publishDictionaryDelete', () => { + test('no-ops when replicaSync is not initialized', async () => { + (getReplicaSync as ReturnType).mockReturnValue(null); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryDelete('content-hash-abc'); + }); + + test('no-ops when user not authenticated', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue(null); + await publishDictionaryDelete('content-hash-abc'); + expect(ctx.manager.markDirty).not.toHaveBeenCalled(); + }); + + test('produces a tombstoned row (deleted_at_ts set)', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryDelete('content-hash-abc'); + expect(ctx.manager.markDirty).toHaveBeenCalledOnce(); + const row = ctx.manager.markDirty.mock.calls[0]![0]; + expect(row.replica_id).toBe('content-hash-abc'); + expect(row.kind).toBe('dictionary'); + expect(row.deleted_at_ts).not.toBe(null); + }); + + test('tombstone HLC matches updated_at_ts (remove-wins ordering)', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryDelete('content-hash-abc'); + const row = ctx.manager.markDirty.mock.calls[0]![0]; + expect(row.updated_at_ts).toBe(row.deleted_at_ts); + }); +}); +describe('publishDictionaryManifest', () => { + test('no-ops when replicaSync is not initialized', async () => { + (getReplicaSync as ReturnType).mockReturnValue(null); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryManifest('content-hash-abc', []); + }); + + test('no-ops when user not authenticated', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue(null); + await publishDictionaryManifest('content-hash-abc', []); + expect(ctx.manager.markDirty).not.toHaveBeenCalled(); + }); + + test('produces a row with manifest_jsonb populated and empty fields_jsonb', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + const files = [ + { filename: 'webster.mdx', byteSize: 1_000_000, partialMd5: 'abc123' }, + { filename: 'webster.mdd', byteSize: 5_000_000, partialMd5: 'def456' }, + ]; + await publishDictionaryManifest('content-hash-abc', files); + expect(ctx.manager.markDirty).toHaveBeenCalledOnce(); + const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow; + expect(row.replica_id).toBe('content-hash-abc'); + expect(row.kind).toBe('dictionary'); + expect(row.manifest_jsonb).toEqual({ files, schemaVersion: 1 }); + expect(row.fields_jsonb).toEqual({}); + expect(row.deleted_at_ts).toBe(null); + }); + + test('manifest publish preserves reincarnation when provided', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryManifest('content-hash-abc', [], 'epoch-1'); + const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow; + expect(row.reincarnation).toBe('epoch-1'); + }); + + test('manifest with no files is valid (e.g., metadata-only refresh)', async () => { + const ctx = makeFakeCtx(); + (getReplicaSync as ReturnType).mockReturnValue(ctx); + (getUserID as ReturnType).mockResolvedValue('user-1'); + await publishDictionaryManifest('content-hash-abc', []); + const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow; + expect(row.manifest_jsonb?.files).toEqual([]); + }); +}); + +// Suppress unused import lint when running standalone +void hlcPack; +void ({} as Hlc); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaPullDictionaries.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaPullDictionaries.test.ts new file mode 100644 index 00000000..8621a052 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/replicaPullDictionaries.test.ts @@ -0,0 +1,319 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + pullDictionariesAndApply, + type PullDictionariesDeps, +} from '@/services/sync/replicaPullDictionaries'; +import { hlcPack } from '@/libs/crdt'; +import type { Hlc, Manifest, ReplicaRow } from '@/types/replica'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; + +const NOW = 1_700_000_000_000; +const DEV = 'dev-a'; + +const baseRow = (overrides: Partial = {}): ReplicaRow => ({ + user_id: 'u1', + kind: 'dictionary', + replica_id: 'content-hash-abc', + fields_jsonb: { + name: { v: 'Webster', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV }, + kind: { v: 'mdict', t: hlcPack(NOW, 1, DEV) as Hlc, s: DEV }, + addedAt: { v: NOW, t: hlcPack(NOW, 2, DEV) as Hlc, s: DEV }, + }, + manifest_jsonb: null, + deleted_at_ts: null, + reincarnation: null, + updated_at_ts: hlcPack(NOW, 2, DEV) as Hlc, + schema_version: 1, + ...overrides, +}); + +const manifest = (filenames: string[]): Manifest => ({ + files: filenames.map((filename) => ({ filename, byteSize: 1, partialMd5: 'x' })), + schemaVersion: 1, +}); + +const baseDict = (overrides: Partial = {}): ImportedDictionary => ({ + id: 'local-1', + contentId: 'content-hash-abc', + kind: 'mdict', + name: 'Webster', + bundleDir: 'local-1', + files: { mdx: 'webster.mdx' }, + addedAt: NOW, + ...overrides, +}); + +const makeDeps = () => { + const findByContentId = vi.fn((_id: string): ImportedDictionary | undefined => undefined); + const deps = { + pull: vi.fn(async () => [] as ReplicaRow[]), + findByContentId, + applyRemoteDictionary: vi.fn(), + softDeleteByContentId: vi.fn(), + createBundleDir: vi.fn(async () => 'fresh-bundle-dir-1'), + queueReplicaDownload: vi.fn(() => 'transfer-id-1'), + // Default: no files exist locally, so the orchestrator queues + // downloads. Tests that exercise the "binaries already on disk" + // path override this. + filesExist: vi.fn(async () => false), + } satisfies PullDictionariesDeps; + return deps; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('pullDictionariesAndApply', () => { + test('no-op when pull returns no rows', async () => { + const deps = makeDeps(); + await pullDictionariesAndApply(deps); + expect(deps.applyRemoteDictionary).not.toHaveBeenCalled(); + expect(deps.queueReplicaDownload).not.toHaveBeenCalled(); + }); + + test('skips entirely (no pull) when isAuthenticated returns false', async () => { + const deps = { + ...makeDeps(), + isAuthenticated: vi.fn(async () => false), + } satisfies PullDictionariesDeps; + await pullDictionariesAndApply(deps); + expect(deps.isAuthenticated).toHaveBeenCalledOnce(); + expect(deps.pull).not.toHaveBeenCalled(); + expect(deps.applyRemoteDictionary).not.toHaveBeenCalled(); + expect(deps.queueReplicaDownload).not.toHaveBeenCalled(); + }); + + test('hydrateLocalStore runs before pull so applyRemoteDictionary auto-persist does not wipe persisted entries', async () => { + const order: string[] = []; + const deps = { + ...makeDeps(), + hydrateLocalStore: vi.fn(async () => { + order.push('hydrate'); + }), + } satisfies PullDictionariesDeps; + (deps.pull as ReturnType).mockImplementation(async () => { + order.push('pull'); + return []; + }); + await pullDictionariesAndApply(deps); + expect(order).toEqual(['hydrate', 'pull']); + }); + + test('proceeds when isAuthenticated returns true', async () => { + const row = baseRow({ manifest_jsonb: manifest(['x.mdx']) }); + const deps = { + ...makeDeps(), + isAuthenticated: vi.fn(async () => true), + } satisfies PullDictionariesDeps; + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).mockReturnValue(undefined); + await pullDictionariesAndApply(deps); + expect(deps.pull).toHaveBeenCalledOnce(); + expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce(); + }); + + test('alive-and-new row: creates bundle dir, applies dict, queues download', async () => { + const row = baseRow({ manifest_jsonb: manifest(['webster.mdx', 'webster.mdd']) }); + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).mockReturnValue(undefined); + + await pullDictionariesAndApply(deps); + + expect(deps.createBundleDir).toHaveBeenCalledOnce(); + expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce(); + const applied = (deps.applyRemoteDictionary as ReturnType).mock.calls[0]![0]; + expect(applied.contentId).toBe('content-hash-abc'); + expect(applied.bundleDir).toBe('fresh-bundle-dir-1'); + expect(applied.unavailable).toBe(true); + + expect(deps.queueReplicaDownload).toHaveBeenCalledOnce(); + const downloadArgs = (deps.queueReplicaDownload as ReturnType).mock.calls[0]; + expect(downloadArgs![0]).toBe('content-hash-abc'); + expect(downloadArgs![1]).toBe('Webster'); + expect(downloadArgs![2]).toEqual([ + { logical: 'webster.mdx', lfp: 'fresh-bundle-dir-1/webster.mdx', byteSize: 1 }, + { logical: 'webster.mdd', lfp: 'fresh-bundle-dir-1/webster.mdd', byteSize: 1 }, + ]); + expect(downloadArgs![3]).toBe('fresh-bundle-dir-1'); + }); + + test('alive-and-new row WITHOUT manifest: applies dict but skips download (binaries pending server-side)', async () => { + const row = baseRow({ manifest_jsonb: null }); + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).mockReturnValue(undefined); + + await pullDictionariesAndApply(deps); + + expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce(); + expect(deps.queueReplicaDownload).not.toHaveBeenCalled(); + }); + + test('alive-and-already-local row WITH local binaries: does NOT re-create or re-download', async () => { + const row = baseRow({ manifest_jsonb: manifest(['webster.mdx']) }); + const local = baseDict(); + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).mockReturnValue(local); + (deps.filesExist as ReturnType).mockResolvedValue(true); + + await pullDictionariesAndApply(deps); + + expect(deps.createBundleDir).not.toHaveBeenCalled(); + expect(deps.applyRemoteDictionary).not.toHaveBeenCalled(); + expect(deps.filesExist).toHaveBeenCalledWith('local-1', ['webster.mdx']); + expect(deps.queueReplicaDownload).not.toHaveBeenCalled(); + }); + + test('alive-and-already-local row WITH binaries missing: re-downloads into the existing bundleDir', async () => { + const row = baseRow({ manifest_jsonb: manifest(['webster.mdx', 'webster.mdd']) }); + const local = baseDict(); + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).mockReturnValue(local); + // Default filesExist returns false → recovery path. + + await pullDictionariesAndApply(deps); + + expect(deps.createBundleDir).not.toHaveBeenCalled(); + expect(deps.applyRemoteDictionary).not.toHaveBeenCalled(); + expect(deps.queueReplicaDownload).toHaveBeenCalledOnce(); + const downloadArgs = (deps.queueReplicaDownload as ReturnType).mock.calls[0]; + // bundleDir is the existing local entry's, NOT a fresh one. + expect(downloadArgs![3]).toBe('local-1'); + expect(downloadArgs![2]).toEqual([ + { logical: 'webster.mdx', lfp: 'local-1/webster.mdx', byteSize: 1 }, + { logical: 'webster.mdd', lfp: 'local-1/webster.mdd', byteSize: 1 }, + ]); + }); + + test('alive-and-new row WITH binaries already on disk: applies but does NOT queue download', async () => { + const row = baseRow({ manifest_jsonb: manifest(['webster.mdx']) }); + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).mockReturnValue(undefined); + (deps.filesExist as ReturnType).mockResolvedValue(true); + + await pullDictionariesAndApply(deps); + + expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce(); + expect(deps.queueReplicaDownload).not.toHaveBeenCalled(); + }); + + test('tombstoned row (no reincarnation): soft-delete the local entry if alive', async () => { + const tombstone = hlcPack(NOW + 1000, 0, DEV) as Hlc; + const row = baseRow({ + deleted_at_ts: tombstone, + updated_at_ts: tombstone, + reincarnation: null, + }); + const local = baseDict(); + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).mockReturnValue(local); + + await pullDictionariesAndApply(deps); + + expect(deps.softDeleteByContentId).toHaveBeenCalledWith('content-hash-abc'); + expect(deps.applyRemoteDictionary).not.toHaveBeenCalled(); + }); + + test('tombstoned row already gone locally: no-op (idempotent)', async () => { + const tombstone = hlcPack(NOW + 1000, 0, DEV) as Hlc; + const row = baseRow({ + deleted_at_ts: tombstone, + updated_at_ts: tombstone, + reincarnation: null, + }); + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).mockReturnValue(undefined); + + await pullDictionariesAndApply(deps); + + expect(deps.softDeleteByContentId).not.toHaveBeenCalled(); + expect(deps.applyRemoteDictionary).not.toHaveBeenCalled(); + }); + + test('reincarnated row (alive again): treated as alive — creates locally if absent', async () => { + const tombstone = hlcPack(NOW, 0, DEV) as Hlc; + const row = baseRow({ + deleted_at_ts: tombstone, + reincarnation: 'epoch-1', + manifest_jsonb: manifest(['webster.mdx']), + updated_at_ts: hlcPack(NOW + 1000, 0, DEV) as Hlc, + }); + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).mockReturnValue(undefined); + + await pullDictionariesAndApply(deps); + + expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce(); + const applied = (deps.applyRemoteDictionary as ReturnType).mock.calls[0]![0]; + expect(applied.reincarnation).toBe('epoch-1'); + expect(deps.queueReplicaDownload).toHaveBeenCalledOnce(); + }); + + test('malformed row (missing kind): skipped, no apply, no download', async () => { + const row = baseRow(); + delete (row.fields_jsonb as Record)['kind']; + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([row]); + + await pullDictionariesAndApply(deps); + + expect(deps.applyRemoteDictionary).not.toHaveBeenCalled(); + expect(deps.queueReplicaDownload).not.toHaveBeenCalled(); + }); + + test('multiple rows: each applied independently', async () => { + const r1 = baseRow({ + replica_id: 'hash-A', + fields_jsonb: { + ...baseRow().fields_jsonb, + name: { v: 'Dict A', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV }, + }, + manifest_jsonb: manifest(['a.mdx']), + }); + const r2 = baseRow({ + replica_id: 'hash-B', + fields_jsonb: { + ...baseRow().fields_jsonb, + name: { v: 'Dict B', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV }, + }, + manifest_jsonb: manifest(['b.mdx']), + }); + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([r1, r2]); + (deps.findByContentId as ReturnType).mockReturnValue(undefined); + let bundleCounter = 0; + (deps.createBundleDir as ReturnType).mockImplementation( + async () => `bundle-${++bundleCounter}`, + ); + + await pullDictionariesAndApply(deps); + + expect(deps.applyRemoteDictionary).toHaveBeenCalledTimes(2); + expect(deps.queueReplicaDownload).toHaveBeenCalledTimes(2); + }); + + test('one malformed row does not block others', async () => { + const goodRow = baseRow({ manifest_jsonb: manifest(['x.mdx']) }); + const badRow = baseRow({ replica_id: 'hash-bad' }); + delete (badRow.fields_jsonb as Record)['kind']; + const deps = makeDeps(); + (deps.pull as ReturnType).mockResolvedValue([badRow, goodRow]); + (deps.findByContentId as ReturnType).mockReturnValue(undefined); + + await pullDictionariesAndApply(deps); + + expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaSyncManager.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaSyncManager.test.ts index 10d854a7..87ae5169 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaSyncManager.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaSyncManager.test.ts @@ -107,6 +107,92 @@ describe('ReplicaSyncManager.markDirty + flush', () => { expect(pushed[0]!.fields_jsonb['name']!.t).toBe(hlcPack(NOW, 1, DEV)); }); + test('same replica metadata + manifest rows coalesce before push', async () => { + const { manager, client } = makeManager(); + const fieldsHlc = hlcPack(NOW, 0, DEV) as Hlc; + const manifestHlc = hlcPack(NOW, 1, DEV) as Hlc; + manager.markDirty(makeRow('r1', fieldsHlc)); + manager.markDirty({ + ...makeRow('r1', manifestHlc), + fields_jsonb: {}, + manifest_jsonb: { + schemaVersion: 1, + files: [{ filename: 'webster.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }], + }, + reincarnation: 'epoch-1', + }); + + await manager.flush(); + + const pushed = client.push.mock.calls[0]![0]; + expect(pushed).toHaveLength(1); + expect(pushed[0]!.fields_jsonb['name']?.v).toBe('r1'); + expect(pushed[0]!.manifest_jsonb?.files).toHaveLength(1); + expect(pushed[0]!.reincarnation).toBe('epoch-1'); + expect(pushed[0]!.updated_at_ts).toBe(manifestHlc); + }); + + test('coalescing metadata after manifest preserves the manifest', async () => { + const { manager, client } = makeManager(); + const manifestHlc = hlcPack(NOW, 0, DEV) as Hlc; + const metadataHlc = hlcPack(NOW, 1, DEV) as Hlc; + manager.markDirty({ + ...makeRow('r1', manifestHlc), + manifest_jsonb: { + schemaVersion: 1, + files: [{ filename: 'webster.mdx', byteSize: 1000, partialMd5: 'a'.repeat(32) }], + }, + }); + manager.markDirty({ + ...makeRow('r1', metadataHlc), + fields_jsonb: { name: { v: 'Renamed', t: metadataHlc, s: DEV } }, + manifest_jsonb: null, + }); + + await manager.flush(); + + const pushed = client.push.mock.calls[0]![0]; + expect(pushed[0]!.fields_jsonb['name']?.v).toBe('Renamed'); + expect(pushed[0]!.manifest_jsonb?.files).toHaveLength(1); + expect(pushed[0]!.updated_at_ts).toBe(metadataHlc); + }); + + test('coalescing metadata with null reincarnation stays null when no token exists', async () => { + const { manager, client } = makeManager(); + const revivedHlc = hlcPack(NOW, 0, DEV) as Hlc; + const metadataHlc = hlcPack(NOW, 1, DEV) as Hlc; + manager.markDirty(makeRow('r1', revivedHlc)); + manager.markDirty({ + ...makeRow('r1', metadataHlc), + fields_jsonb: { name: { v: 'Renamed', t: metadataHlc, s: DEV } }, + reincarnation: null, + }); + + await manager.flush(); + + const pushed = client.push.mock.calls[0]![0]; + expect(pushed[0]!.fields_jsonb['name']?.v).toBe('Renamed'); + expect(pushed[0]!.reincarnation).toBe(null); + }); + + test('coalescing metadata after a reincarnated row preserves the token', async () => { + const { manager, client } = makeManager(); + const revivedHlc = hlcPack(NOW, 0, DEV) as Hlc; + const metadataHlc = hlcPack(NOW, 1, DEV) as Hlc; + manager.markDirty({ ...makeRow('r1', revivedHlc), reincarnation: 'epoch-1' }); + manager.markDirty({ + ...makeRow('r1', metadataHlc), + fields_jsonb: { name: { v: 'Renamed', t: metadataHlc, s: DEV } }, + reincarnation: null, + }); + + await manager.flush(); + + const pushed = client.push.mock.calls[0]![0]; + expect(pushed[0]!.fields_jsonb['name']?.v).toBe('Renamed'); + expect(pushed[0]!.reincarnation).toBe('epoch-1'); + }); + test('flush() clears the dirty set on success', async () => { const { manager, client } = makeManager(); manager.markDirty(makeRow('r1')); @@ -180,4 +266,31 @@ describe('ReplicaSyncManager.pull', () => { await manager.pull('dictionary'); expect(cursors.get('dictionary')).toBeUndefined(); }); + + test('pull with { since: null } bypasses an existing cursor', async () => { + const r1 = makeRow('r1', hlcPack(NOW + 100, 0, DEV) as Hlc); + const client = { + ...makeFakeClient(), + pull: vi.fn().mockResolvedValueOnce([r1]).mockResolvedValueOnce([r1]), + }; + const { manager } = makeManager(client); + await manager.pull('dictionary'); + // After the first pull, the cursor is at r1.updated_at_ts. A normal + // second pull would pass that cursor; the boot pull explicitly + // requests since=null to do a full re-sync. + await manager.pull('dictionary', { since: null }); + expect(client.pull).toHaveBeenNthCalledWith(2, 'dictionary', null); + }); + + test('full pull still advances the cursor when rows arrive', async () => { + const r1 = makeRow('r1', hlcPack(NOW + 100, 0, DEV) as Hlc); + const r2 = makeRow('r2', hlcPack(NOW + 200, 0, DEV) as Hlc); + const client = { + ...makeFakeClient(), + pull: vi.fn(async () => [r1, r2]), + }; + const { manager, cursors } = makeManager(client); + await manager.pull('dictionary', { since: null }); + expect(cursors.get('dictionary')).toBe(r2.updated_at_ts); + }); }); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaTransferIntegration.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaTransferIntegration.test.ts new file mode 100644 index 00000000..d56770c8 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/replicaTransferIntegration.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@/services/sync/replicaPublish', () => ({ + publishDictionaryManifest: vi.fn(), +})); + +const markAvailableByContentId = vi.fn(); +vi.mock('@/store/customDictionaryStore', () => ({ + useCustomDictionaryStore: { + getState: () => ({ markAvailableByContentId }), + }, +})); + +import { eventDispatcher } from '@/utils/event'; +import { publishDictionaryManifest } from '@/services/sync/replicaPublish'; +import { + __resetReplicaTransferIntegrationForTests, + startReplicaTransferIntegration, +} from '@/services/sync/replicaTransferIntegration'; +import type { AppService } from '@/types/system'; + +const mockPublish = publishDictionaryManifest as ReturnType; + +const makeFakeAppService = () => { + const close = vi.fn(); + return { + openFile: vi.fn(async (path: string) => { + const content = `content-of-${path}`; + return new File([content], path, { type: 'application/octet-stream' }); + }), + _close: close, + }; +}; + +beforeEach(() => { + __resetReplicaTransferIntegrationForTests(); + vi.clearAllMocks(); +}); + +afterEach(() => { + __resetReplicaTransferIntegrationForTests(); + vi.restoreAllMocks(); +}); + +describe('replicaTransferIntegration', () => { + test('upload event triggers publishDictionaryManifest', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + mockPublish.mockResolvedValue(undefined); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'dictionary', + replicaId: 'content-hash-abc', + type: 'upload', + files: [ + { logical: 'webster.mdx', lfp: 'b/webster.mdx', byteSize: 1000 }, + { logical: 'webster.mdd', lfp: 'b/webster.mdd', byteSize: 2000 }, + ], + }); + + expect(mockPublish).toHaveBeenCalledOnce(); + const [contentId, manifestFiles] = mockPublish.mock.calls[0]!; + expect(contentId).toBe('content-hash-abc'); + expect(manifestFiles).toHaveLength(2); + expect(manifestFiles[0]!.filename).toBe('webster.mdx'); + expect(manifestFiles[0]!.byteSize).toBe(1000); + expect(manifestFiles[0]!.partialMd5).toMatch(/^[0-9a-f]{32}$/); + }); + + test('upload event carries reincarnation token into manifest publish', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + mockPublish.mockResolvedValue(undefined); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'dictionary', + replicaId: 'content-hash-abc', + reincarnation: 'epoch-1', + type: 'upload', + files: [{ logical: 'webster.mdx', lfp: 'b/webster.mdx', byteSize: 1000 }], + }); + + expect(mockPublish).toHaveBeenCalledOnce(); + expect(mockPublish.mock.calls[0]![2]).toBe('epoch-1'); + }); + + test('download event does NOT publish a manifest (publish is upload-side only)', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'dictionary', + replicaId: 'content-hash-abc', + type: 'download', + files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 10 }], + }); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + test('download event marks the local dict available (clears unavailable flag)', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'dictionary', + replicaId: 'content-hash-abc', + type: 'download', + files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 10 }], + }); + expect(markAvailableByContentId).toHaveBeenCalledOnce(); + expect(markAvailableByContentId).toHaveBeenCalledWith('content-hash-abc'); + }); + + test('upload event does NOT mark available (only download finishes the placeholder lifecycle)', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + mockPublish.mockResolvedValue(undefined); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'dictionary', + replicaId: 'content-hash-abc', + type: 'upload', + files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 10 }], + }); + expect(markAvailableByContentId).not.toHaveBeenCalled(); + }); + + test('non-dictionary download event is ignored (no markAvailable)', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'font', + replicaId: 'font-hash', + type: 'download', + files: [{ logical: 'r.ttf', lfp: 'f/r.ttf', byteSize: 1 }], + }); + expect(markAvailableByContentId).not.toHaveBeenCalled(); + }); + + test('delete event is ignored', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'dictionary', + replicaId: 'content-hash-abc', + type: 'delete', + filenames: ['x.mdx'], + }); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + test('non-dictionary kind is ignored (this slice ships dictionary only)', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'font', + replicaId: 'font-hash', + type: 'upload', + files: [{ logical: 'r.ttf', lfp: 'f/r.ttf', byteSize: 1 }], + }); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + test('upload event with no files is ignored', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'dictionary', + replicaId: 'content-hash-abc', + type: 'upload', + files: [], + }); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + test('start is idempotent — second call does not double-register', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + startReplicaTransferIntegration(appService); + + await eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'dictionary', + replicaId: 'content-hash-abc', + type: 'upload', + files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 100 }], + }); + expect(mockPublish).toHaveBeenCalledTimes(1); + }); + + test('publish error is caught (does not bubble up to event dispatcher)', async () => { + const appService = makeFakeAppService() as unknown as AppService; + startReplicaTransferIntegration(appService); + mockPublish.mockRejectedValueOnce(new Error('network outage')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect( + eventDispatcher.dispatch('replica-transfer-complete', { + kind: 'dictionary', + replicaId: 'content-hash-abc', + type: 'upload', + files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 1 }], + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/transfer-manager.test.ts b/apps/readest-app/src/__tests__/services/transfer-manager.test.ts index df09af48..2d969ea0 100644 --- a/apps/readest-app/src/__tests__/services/transfer-manager.test.ts +++ b/apps/readest-app/src/__tests__/services/transfer-manager.test.ts @@ -64,6 +64,12 @@ const resetTransferManager = () => { mgr['updateBook'] = null; mgr['_'] = null; (mgr['abortControllers'] as Map).clear(); + // Re-arm the readyPromise so each test starts with a pending one. + let resolveReady: () => void = () => {}; + mgr['readyPromise'] = new Promise((res) => { + resolveReady = res; + }); + mgr['readyResolve'] = resolveReady; }; const resetTransferStore = () => { @@ -131,6 +137,43 @@ describe('TransferManager', () => { }); }); + // ── waitUntilReady ─────────────────────────────────────────────── + describe('waitUntilReady', () => { + test('does not resolve before initialize', async () => { + let resolved = false; + void transferManager.waitUntilReady().then(() => { + resolved = true; + }); + // Yield to the microtask queue so any spurious early resolution would land. + await Promise.resolve(); + await Promise.resolve(); + expect(resolved).toBe(false); + }); + + test('resolves once initialize completes', async () => { + let resolved = false; + void transferManager.waitUntilReady().then(() => { + resolved = true; + }); + const appService = makeAppService(); + await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn); + await Promise.resolve(); + expect(resolved).toBe(true); + }); + + test('returns an already-resolved promise after initialize', async () => { + const appService = makeAppService(); + await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn); + // After init this should resolve in the next microtask, not block. + let resolved = false; + void transferManager.waitUntilReady().then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(true); + }); + }); + // ── initialize ─────────────────────────────────────────────────── describe('initialize', () => { test('loads persisted queue from localStorage', async () => { @@ -702,6 +745,7 @@ describe('TransferManager', () => { 'Webster', dictFiles, 'Dictionaries', + { reincarnation: 'epoch-1' }, ); expect(id).toBeTruthy(); const t = useTransferStore.getState().transfers[id!]!; @@ -710,6 +754,7 @@ describe('TransferManager', () => { expect(t.replicaId).toBe('d1'); expect(t.replicaFiles).toEqual(dictFiles); expect(t.replicaBase).toBe('Dictionaries'); + expect(t.replicaReincarnation).toBe('epoch-1'); expect(t.totalBytes).toBe(5000); }); @@ -774,6 +819,35 @@ describe('TransferManager', () => { expect(t.replicaFiles).toEqual(dictFiles); }); + test('executing a replica download passes the transfer base to downloadReplicaFile', async () => { + const appService = makeAppService(); + const downloadSpy = vi.fn().mockResolvedValue(undefined); + appService['downloadReplicaFile'] = downloadSpy; + await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn); + + transferManager.queueReplicaDownload( + 'dictionary', + 'content-hash-abc', + 'Webster', + [{ logical: 'webster.mdx', lfp: 'bundle-1/webster.mdx', byteSize: 1000 }], + 'Dictionaries', + ); + + // Drain the queue. + await vi.runAllTimersAsync(); + await Promise.resolve(); + await Promise.resolve(); + + expect(downloadSpy).toHaveBeenCalledOnce(); + // Args: (kind, replicaId, filename, lfp, base, onProgress) + const call = downloadSpy.mock.calls[0]!; + expect(call[0]).toBe('dictionary'); + expect(call[1]).toBe('content-hash-abc'); + expect(call[2]).toBe('webster.mdx'); + expect(call[3]).toBe('bundle-1/webster.mdx'); + expect(call[4]).toBe('Dictionaries'); + }); + test('queueReplicaDelete creates a delete-typed transfer with filename list', async () => { const appService = makeAppService(); appService['deleteReplicaBundle'] = vi.fn().mockResolvedValue(undefined); diff --git a/apps/readest-app/src/__tests__/services/transferMessages.test.ts b/apps/readest-app/src/__tests__/services/transferMessages.test.ts new file mode 100644 index 00000000..9e1ecff3 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/transferMessages.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'vitest'; +import { getTransferMessages } from '@/services/transferMessages'; +import type { TransferItem } from '@/store/transferStore'; +import type { TranslationFunc } from '@/hooks/useTranslation'; + +const passthroughT: TranslationFunc = (key, params) => { + if (!params) return key; + return Object.entries(params).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key); +}; + +const baseTransfer = (overrides: Partial = {}): TransferItem => + ({ + id: 't1', + kind: 'book', + bookHash: 'h1', + bookTitle: 'Moby Dick', + type: 'upload', + status: 'pending', + progress: 0, + totalBytes: 0, + transferredBytes: 0, + transferSpeed: 0, + retryCount: 0, + maxRetries: 3, + createdAt: 0, + priority: 10, + isBackground: false, + ...overrides, + }) as TransferItem; + +describe('getTransferMessages', () => { + test('book transfer uses "Book" copy', () => { + const m = getTransferMessages(baseTransfer({ bookTitle: 'Moby Dick' }), passthroughT); + expect(m.success.upload).toBe('Book uploaded: Moby Dick'); + expect(m.success.download).toBe('Book downloaded: Moby Dick'); + expect(m.success.delete).toBe('Deleted cloud backup of the book: Moby Dick'); + expect(m.failure.upload).toBe('Failed to upload book: Moby Dick'); + expect(m.failure.download).toBe('Failed to download book: Moby Dick'); + expect(m.failure.delete).toBe('Failed to delete cloud backup of the book: Moby Dick'); + }); + + test('dictionary replica transfer uses "Dictionary" copy', () => { + const m = getTransferMessages( + baseTransfer({ + kind: 'replica', + replicaKind: 'dictionary', + bookHash: '', + bookTitle: 'Longman Phrasal Verbs', + }), + passthroughT, + ); + expect(m.success.upload).toBe('Dictionary uploaded: Longman Phrasal Verbs'); + expect(m.success.download).toBe('Dictionary downloaded: Longman Phrasal Verbs'); + expect(m.success.delete).toBe('Deleted cloud copy of the dictionary: Longman Phrasal Verbs'); + expect(m.failure.upload).toBe('Failed to upload dictionary: Longman Phrasal Verbs'); + expect(m.failure.download).toBe('Failed to download dictionary: Longman Phrasal Verbs'); + expect(m.failure.delete).toBe( + 'Failed to delete cloud copy of the dictionary: Longman Phrasal Verbs', + ); + }); + + test('replica transfer with unknown replicaKind falls back to book copy (defensive)', () => { + const m = getTransferMessages( + baseTransfer({ kind: 'replica', replicaKind: 'font', bookTitle: 'Roboto' }), + passthroughT, + ); + // Until 'font' ships explicit copy, the safe fallback is the existing + // book strings. Updating per-kind copy is paired with adding the kind. + expect(m.success.upload).toBe('Book uploaded: Roboto'); + }); +}); diff --git a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts index 11ed7554..9e5cfab4 100644 --- a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts +++ b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts @@ -1,11 +1,27 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { useCustomDictionaryStore } from '@/store/customDictionaryStore'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('@/services/sync/replicaPublish', () => ({ + publishDictionaryDelete: vi.fn(), + publishDictionaryUpsert: vi.fn(), +})); + +import { + useCustomDictionaryStore, + enableReplicaAutoPersist, + findDictionaryByContentId, +} from '@/store/customDictionaryStore'; import { BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types'; +import { publishDictionaryUpsert } from '@/services/sync/replicaPublish'; +import { useSettingsStore } from '@/store/settingsStore'; +import type { EnvConfigType } from '@/services/environment'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; const ZERO = (s: string) => s.startsWith('web:builtin:'); +const mockPublishDictionaryUpsert = vi.mocked(publishDictionaryUpsert); describe('customDictionaryStore — web search CRUD', () => { beforeEach(() => { + vi.clearAllMocks(); // Reset state to defaults so tests don't bleed. useCustomDictionaryStore.setState({ dictionaries: [], @@ -123,4 +139,158 @@ describe('customDictionaryStore — web search CRUD', () => { // Unknown id: silent no-op. expect(() => updateDictionary('mdict:nope', { name: 'X' })).not.toThrow(); }); + + describe('replica auto-persist', () => { + const baseDict = (overrides: Partial = {}): ImportedDictionary => ({ + id: 'remote-bundle-1', + contentId: 'content-hash-1', + kind: 'mdict', + name: 'Remote Webster', + bundleDir: 'remote-bundle-1', + files: { mdx: 'webster.mdx' }, + addedAt: 1, + unavailable: true, + ...overrides, + }); + + const setupSpyEnv = () => { + // saveCustomDictionaries calls setSettings + saveSettings on the + // settings store. Spy both to assert the chain fires. + const setSettings = vi.spyOn(useSettingsStore.getState(), 'setSettings'); + const saveSettings = vi + .spyOn(useSettingsStore.getState(), 'saveSettings') + .mockResolvedValue(undefined); + const fakeEnv = { name: 'test-env' } as unknown as EnvConfigType; + enableReplicaAutoPersist(fakeEnv); + return { setSettings, saveSettings, fakeEnv }; + }; + + it('applyRemoteDictionary persists state via saveCustomDictionaries when env is registered', async () => { + const { setSettings, saveSettings, fakeEnv } = setupSpyEnv(); + useCustomDictionaryStore.getState().applyRemoteDictionary(baseDict()); + + // setSettings runs synchronously inside saveCustomDictionaries; the + // microtask queue flush makes the fire-and-forget save observable. + await Promise.resolve(); + await Promise.resolve(); + expect(setSettings).toHaveBeenCalled(); + expect(saveSettings).toHaveBeenCalledWith(fakeEnv, expect.any(Object)); + const persisted = setSettings.mock.calls.at(-1)![0]; + expect(persisted.customDictionaries?.some((d) => d.id === 'remote-bundle-1')).toBe(true); + }); + + it('softDeleteByContentId persists state via saveCustomDictionaries when env is registered', async () => { + const { saveSettings } = setupSpyEnv(); + // Seed an alive dict to be tombstoned. + useCustomDictionaryStore.getState().applyRemoteDictionary(baseDict()); + saveSettings.mockClear(); + + useCustomDictionaryStore.getState().softDeleteByContentId('content-hash-1'); + await Promise.resolve(); + await Promise.resolve(); + expect(saveSettings).toHaveBeenCalledOnce(); + }); + + it('does not persist when env has not been registered', async () => { + // Wipe the registry by re-enabling with null-equivalent. We expose + // enableReplicaAutoPersist with a nullable arg for test isolation. + enableReplicaAutoPersist(null); + const setSettings = vi.spyOn(useSettingsStore.getState(), 'setSettings'); + const saveSettings = vi + .spyOn(useSettingsStore.getState(), 'saveSettings') + .mockResolvedValue(undefined); + + useCustomDictionaryStore.getState().applyRemoteDictionary(baseDict()); + await Promise.resolve(); + await Promise.resolve(); + expect(setSettings).not.toHaveBeenCalled(); + expect(saveSettings).not.toHaveBeenCalled(); + }); + }); + + describe('findDictionaryByContentId', () => { + it('returns the in-memory dict when present', () => { + useCustomDictionaryStore.getState().applyRemoteDictionary({ + id: 'in-mem-1', + contentId: 'hash-1', + kind: 'mdict', + name: 'In Memory', + bundleDir: 'in-mem-1', + files: { mdx: 'm.mdx' }, + addedAt: 1, + }); + const found = findDictionaryByContentId('hash-1'); + expect(found?.id).toBe('in-mem-1'); + }); + + it('falls back to settings.customDictionaries when in-memory store has no match', () => { + // Simulate fresh-boot state: in-memory store empty, but persisted + // settings (loaded from disk) carries the dict. + useSettingsStore.setState({ + settings: { + customDictionaries: [ + { + id: 'persisted-1', + contentId: 'hash-2', + kind: 'mdict', + name: 'Persisted', + bundleDir: 'persisted-1', + files: { mdx: 'p.mdx' }, + addedAt: 1, + }, + ], + } as never, + }); + const found = findDictionaryByContentId('hash-2'); + expect(found?.id).toBe('persisted-1'); + }); + + it('returns undefined when neither store has it', () => { + useSettingsStore.setState({ settings: {} as never }); + expect(findDictionaryByContentId('hash-nope')).toBeUndefined(); + }); + + it('skips tombstoned persisted entries', () => { + useSettingsStore.setState({ + settings: { + customDictionaries: [ + { + id: 'tombstoned-1', + contentId: 'hash-3', + kind: 'mdict', + name: 'Tombstoned', + bundleDir: 'tombstoned-1', + files: { mdx: 'p.mdx' }, + addedAt: 1, + deletedAt: 100, + }, + ], + } as never, + }); + expect(findDictionaryByContentId('hash-3')).toBeUndefined(); + }); + }); + + it('updateDictionary preserves reincarnation when publishing a renamed dictionary', () => { + const { addDictionary, updateDictionary } = useCustomDictionaryStore.getState(); + addDictionary({ + id: 'mdict:abc', + contentId: 'content-abc', + kind: 'mdict', + name: 'Old title', + bundleDir: 'abc', + files: { mdx: 'abc.mdx' }, + addedAt: 1, + reincarnation: 'epoch-1', + }); + + mockPublishDictionaryUpsert.mockClear(); + updateDictionary('mdict:abc', { name: 'New title' }); + + expect(mockPublishDictionaryUpsert).toHaveBeenCalledOnce(); + expect(mockPublishDictionaryUpsert.mock.calls[0]![0]).toMatchObject({ + name: 'New title', + reincarnation: 'epoch-1', + }); + }); }); diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index b643ff08..ecb3da69 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -79,6 +79,7 @@ import LibraryHeader from './components/LibraryHeader'; import Bookshelf from './components/Bookshelf'; import GroupHeader from './components/GroupHeader'; import useShortcuts from '@/hooks/useShortcuts'; +import { useReplicaPull } from '@/hooks/useReplicaPull'; import DropIndicator from '@/components/DropIndicator'; import SettingsDialog from '@/components/settings/SettingsDialog'; import ModalPortal from '@/components/ModalPortal'; @@ -114,6 +115,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const { settings, setSettings, saveSettings } = useSettingsStore(); const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore(); const { isTransferQueueOpen } = useTransferStore(); + + // Library page pulls dictionaries (custom mdict/stardict bundles synced + // across devices). Deferred 10s; module-scoped dedup means a later + // navigation to the reader won't re-pull the same kind. + useReplicaPull({ kinds: ['dictionary'] }); const [showCatalogManager, setShowCatalogManager] = useState( searchParams?.get('opds') === 'true', ); diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index d482e9a3..21254c9e 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -16,6 +16,7 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useDeviceControlStore } from '@/store/deviceStore'; import { useScreenWakeLock } from '@/hooks/useScreenWakeLock'; import { useTransferQueue } from '@/hooks/useTransferQueue'; +import { useReplicaPull } from '@/hooks/useReplicaPull'; import { eventDispatcher } from '@/utils/event'; import { interceptWindowOpen } from '@/utils/open'; import { mountAdditionalFonts } from '@/styles/fonts'; @@ -73,6 +74,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' }); useScreenWakeLock(settings.screenWakeLock); useTransferQueue(libraryLoaded, 5000); + // Reader needs custom dictionaries for word-lookup providers. Mounted + // here (not in the app-router page wrapper) so the web pages-router + // entry at `pages/reader/[ids].tsx` also gets the pull. Module-scoped + // dedup means navigating between library and reader doesn't re-pull. + useReplicaPull({ kinds: ['dictionary'] }); useEffect(() => { mountAdditionalFonts(document); diff --git a/apps/readest-app/src/components/settings/CustomDictionaries.tsx b/apps/readest-app/src/components/settings/CustomDictionaries.tsx index bd369d27..330249df 100644 --- a/apps/readest-app/src/components/settings/CustomDictionaries.tsx +++ b/apps/readest-app/src/components/settings/CustomDictionaries.tsx @@ -27,6 +27,7 @@ import { useCustomDictionaryStore } from '@/store/customDictionaryStore'; import { eventDispatcher } from '@/utils/event'; import { evictProvider } from '@/services/dictionaries/registry'; import { BUILTIN_PROVIDER_IDS } from '@/services/dictionaries/types'; +import { queueDictionaryBinaryUpload } from '@/services/sync/replicaBinaryUpload'; import type { ImportedDictionary, WebSearchEntry } from '@/services/dictionaries/types'; import { getBuiltinWebSearch, @@ -389,11 +390,13 @@ const CustomDictionaries: React.FC = ({ onBack }) => { let added = 0; for (const dict of importResult.imported) { addDictionary(dict); + if (appService) void queueDictionaryBinaryUpload(dict, appService); added += 1; } let replaced = 0; for (const { oldIds, newDict } of importResult.replacements) { replaceDictionaries(oldIds, newDict); + if (appService) void queueDictionaryBinaryUpload(newDict, appService); // Invalidate any cached provider instances for the replaced ids so // their next lookup picks up the new bundle's files. for (const oldId of oldIds) evictProvider(oldId); diff --git a/apps/readest-app/src/context/EnvContext.tsx b/apps/readest-app/src/context/EnvContext.tsx index 31ab97ac..c39579e4 100644 --- a/apps/readest-app/src/context/EnvContext.tsx +++ b/apps/readest-app/src/context/EnvContext.tsx @@ -5,6 +5,10 @@ import { EnvConfigType } from '../services/environment'; import { AppService } from '@/types/system'; import env from '../services/environment'; import { bootstrapReplicaAdapters } from '@/services/sync/replicaBootstrap'; +import { initReplicaSync } from '@/services/sync/replicaSync'; +import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore'; +import { startReplicaTransferIntegration } from '@/services/sync/replicaTransferIntegration'; +import { enableReplicaAutoPersist } from '@/store/customDictionaryStore'; interface EnvContextType { envConfig: EnvConfigType; @@ -19,7 +23,23 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => { React.useEffect(() => { bootstrapReplicaAdapters(); - envConfig.getAppService().then((service) => setAppService(service)); + enableReplicaAutoPersist(envConfig); + envConfig.getAppService().then(async (service) => { + setAppService(service); + try { + const settings = await service.loadSettings(); + if (settings.replicaDeviceId) { + const ctx = initReplicaSync({ + deviceId: settings.replicaDeviceId, + cursorStore: createSettingsCursorStore(service), + }); + ctx.manager.startAutoSync(); + startReplicaTransferIntegration(service); + } + } catch (err) { + console.warn('replica sync init failed', err); + } + }); window.addEventListener('error', (e) => { if (e.message === 'ResizeObserver loop limit exceeded') { e.stopImmediatePropagation(); diff --git a/apps/readest-app/src/hooks/useReplicaPull.ts b/apps/readest-app/src/hooks/useReplicaPull.ts new file mode 100644 index 00000000..6581c35a --- /dev/null +++ b/apps/readest-app/src/hooks/useReplicaPull.ts @@ -0,0 +1,143 @@ +import { useEffect } from 'react'; +import { useEnv } from '@/context/EnvContext'; +import { useCustomDictionaryStore, findDictionaryByContentId } from '@/store/customDictionaryStore'; +import { transferManager } from '@/services/transferManager'; +import { getReplicaSync } from '@/services/sync/replicaSync'; +import { pullDictionariesAndApply } from '@/services/sync/replicaPullDictionaries'; +import { getAccessToken } from '@/utils/access'; +import { uniqueId } from '@/utils/misc'; +import type { EnvConfigType } from '@/services/environment'; +import type { AppService, BaseDir } from '@/types/system'; +import type { ReplicaSyncManager } from '@/services/sync/replicaSyncManager'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; +import type { ReplicaTransferFile } from '@/store/transferStore'; + +export type ReplicaKind = 'dictionary'; + +export interface UseReplicaPullOpts { + /** Replica kinds this page wants pulled. */ + kinds: readonly ReplicaKind[]; + /** Delay before firing the pull. Defaults to 10s — keeps the boot + * critical path clean and lets feature mounts hydrate first. */ + delayMs?: number; +} + +const REPLICA_PULL_DEFAULT_DELAY_MS = 10_000; + +// Module-level dedup so navigating between pages (library → reader → …) +// doesn't fire a fresh pull every time. Periodic resync is handled by +// ReplicaSyncManager.startAutoSync (visibility / online listeners), +// which is wired once in EnvContext. +const pulledKinds = new Set(); + +const buildDictionaryPullDeps = ( + manager: ReplicaSyncManager, + service: AppService, + envConfig: EnvConfigType, +) => ({ + // Boot path uses since=null so we always re-fetch and apply locally, + // ignoring any previously-advanced cursor. Periodic sync (visibility / + // online) goes through manager.pull(kind) which keeps using the cursor. + pull: () => manager.pull('dictionary', { since: null }), + // The page may mount before loadCustomDictionaries has hydrated the + // in-memory store, so the dedup helper falls back to settings. + findByContentId: (id: string) => findDictionaryByContentId(id), + // Pull-side relies on the in-memory dict store reflecting persisted + // state — without this, the auto-persist fired by applyRemoteDictionary + // would write back only the just-applied rows and clobber every + // persisted dict that hadn't been hydrated by an Annotator/Settings + // mount. Library-page refreshes were the visible victim. + hydrateLocalStore: () => useCustomDictionaryStore.getState().loadCustomDictionaries(envConfig), + applyRemoteDictionary: (dict: ImportedDictionary) => + useCustomDictionaryStore.getState().applyRemoteDictionary(dict), + softDeleteByContentId: (id: string) => + useCustomDictionaryStore.getState().softDeleteByContentId(id), + createBundleDir: async () => { + const id = uniqueId(); + await service.createDir(id, 'Dictionaries', true); + return id; + }, + queueReplicaDownload: ( + contentId: string, + displayTitle: string, + files: ReplicaTransferFile[], + _bundleDir: string, + base: BaseDir, + ) => transferManager.queueReplicaDownload('dictionary', contentId, displayTitle, files, base), + filesExist: async (bundleDir: string, filenames: string[]) => { + for (const filename of filenames) { + const exists = await service.exists(`${bundleDir}/${filename}`, 'Dictionaries'); + if (!exists) return false; + } + return true; + }, + isAuthenticated: async () => !!(await getAccessToken()), +}); + +const runPullForKind = async ( + kind: ReplicaKind, + service: AppService, + envConfig: EnvConfigType, +): Promise => { + const ctx = getReplicaSync(); + if (!ctx) return; + if (kind === 'dictionary') { + const deps = buildDictionaryPullDeps(ctx.manager, service, envConfig); + await pullDictionariesAndApply(deps); + return; + } + // Future: dispatch to other per-kind orchestrators here. +}; + +/** + * Schedules a deferred replica pull for the requested kinds. Mount this + * on a page that wants those kinds present (library, reader, etc.). + * + * Per-kind dedup is module-scoped: the first mount that schedules a + * pull for `dictionary` claims that kind for the rest of the session; + * subsequent mounts (re-navigation, hot reload, parallel pages mounting + * simultaneously) skip. ReplicaSyncManager.startAutoSync handles the + * "tab regained focus / network came back" resync — this hook is only + * for the initial pull each session. + */ +export const useReplicaPull = ({ + kinds, + delayMs = REPLICA_PULL_DEFAULT_DELAY_MS, +}: UseReplicaPullOpts): void => { + const { envConfig, appService } = useEnv(); + // Stable cache key so the effect doesn't re-run when the caller + // passes a freshly-allocated array literal each render. + const kindsKey = kinds.join(','); + + useEffect(() => { + if (!appService) return; + const ctx = getReplicaSync(); + if (!ctx) return; + + const pendingKinds = kinds.filter((k) => !pulledKinds.has(k)); + if (pendingKinds.length === 0) return; + + const timer = setTimeout(() => { + for (const kind of pendingKinds) { + if (pulledKinds.has(kind)) continue; + // Claim the slot up front so a concurrently-scheduled mount + // (e.g., library + reader mounting back-to-back) doesn't double- + // pull. On failure we release the slot so a subsequent navigation + // can retry. + pulledKinds.add(kind); + void runPullForKind(kind, appService, envConfig).catch((err) => { + console.warn(`replica ${kind} pull failed`, err); + pulledKinds.delete(kind); + }); + } + }, delayMs); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [kindsKey, appService, envConfig, delayMs]); +}; + +/** Test seam — clear the per-kind dedup state between specs. */ +export const __resetReplicaPullForTests = (): void => { + pulledKinds.clear(); +}; diff --git a/apps/readest-app/src/libs/crdt.README.md b/apps/readest-app/src/libs/crdt.README.md index 1c80a373..95429f4c 100644 --- a/apps/readest-app/src/libs/crdt.README.md +++ b/apps/readest-app/src/libs/crdt.README.md @@ -66,10 +66,10 @@ a tombstoned row** — that is the unsafe pattern. To revive, use mergeReplica(local, remote): fields_jsonb ← per-field LWW deleted_at_ts ← max(local, remote) (tombstones never disappear) - reincarnation ← LWW by row updated_at_ts - manifest_jsonb ← whichever side is newer + reincarnation ← newer non-null token; null only clears on newer tombstone + manifest_jsonb ← newer non-null manifest; null rows do not clear it schema_version ← max - updated_at_ts ← max over fields and tombstone + updated_at_ts ← max over fields, tombstone, and row-level ops ``` CRDT properties verified by tests: diff --git a/apps/readest-app/src/libs/crdt.ts b/apps/readest-app/src/libs/crdt.ts index f494f1e1..589dab03 100644 --- a/apps/readest-app/src/libs/crdt.ts +++ b/apps/readest-app/src/libs/crdt.ts @@ -160,23 +160,33 @@ export const mergeReplica = (local: ReplicaRow, remote: ReplicaRow): ReplicaRow const fields = mergeFields(local.fields_jsonb, remote.fields_jsonb); const deleted_at_ts = hlcMax(local.deleted_at_ts, remote.deleted_at_ts); - let reincarnation: string | null; - const localReinc = local.reincarnation; - const remoteReinc = remote.reincarnation; - if (localReinc === remoteReinc) { - reincarnation = localReinc; - } else { - const cmp = hlcCompare(local.updated_at_ts, remote.updated_at_ts); - reincarnation = cmp >= 0 ? localReinc : remoteReinc; - } + const reincarnationCandidates = [ + local.reincarnation ? { token: local.reincarnation, t: local.updated_at_ts } : null, + remote.reincarnation ? { token: remote.reincarnation, t: remote.updated_at_ts } : null, + ].filter((c): c is { token: string; t: Hlc } => c !== null); + const winningReincarnation = + reincarnationCandidates.length === 0 + ? null + : reincarnationCandidates.reduce((a, b) => (hlcCompare(a.t, b.t) >= 0 ? a : b)); + const reincarnation = + winningReincarnation && + (!deleted_at_ts || hlcCompare(winningReincarnation.t, deleted_at_ts) > 0) + ? winningReincarnation.token + : null; const manifest_jsonb = - hlcCompare(remote.updated_at_ts, local.updated_at_ts) > 0 - ? remote.manifest_jsonb - : local.manifest_jsonb; + remote.manifest_jsonb === null + ? local.manifest_jsonb + : local.manifest_jsonb === null + ? remote.manifest_jsonb + : hlcCompare(remote.updated_at_ts, local.updated_at_ts) > 0 + ? remote.manifest_jsonb + : local.manifest_jsonb; const schema_version = Math.max(local.schema_version, remote.schema_version); - const updated_at_ts = computeUpdatedAt(fields, deleted_at_ts); + const contentUpdatedAt = computeUpdatedAt(fields, deleted_at_ts); + const rowUpdatedAt = hlcMax(local.updated_at_ts, remote.updated_at_ts); + const updated_at_ts = hlcMax(contentUpdatedAt, rowUpdatedAt) ?? contentUpdatedAt; return { user_id: local.user_id, diff --git a/apps/readest-app/src/libs/replicaInterpret.ts b/apps/readest-app/src/libs/replicaInterpret.ts new file mode 100644 index 00000000..040112af --- /dev/null +++ b/apps/readest-app/src/libs/replicaInterpret.ts @@ -0,0 +1,19 @@ +import { hlcCompare } from './crdt'; +import type { ReplicaRow } from '@/types/replica'; + +/** + * Interpret remove-wins + reincarnation semantics for a pulled row. + * + * Per the plan: tombstones never disappear at the merge level. A row is + * "alive" if it was never deleted, OR if a reincarnation token was minted + * AFTER the tombstone. The specific check uses HLC ordering — `>=` rather + * than `>` so that a same-HLC reincarnation (mid-tick edge) reads as alive. + * + * Used by the pull-side orchestrator to decide whether to surface a row + * to the local store as a live entry or as a tombstone to soft-delete. + */ +export const isReplicaRowAlive = (row: ReplicaRow): boolean => { + if (!row.deleted_at_ts) return true; + if (!row.reincarnation) return false; + return hlcCompare(row.updated_at_ts, row.deleted_at_ts) >= 0; +}; diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 100679c0..9cb6877a 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -300,9 +300,16 @@ export abstract class BaseAppService implements AppService { kind: string, replicaId: string, filename: string, - dst: string, + lfp: string, + base: BaseDir, onProgress?: ProgressHandler, ) { + // Resolve the relative `/` lfp against the + // replica's base dir before downloading. Mirrors how upload uses + // `resolveFilePath(opts.lfp, opts.base)`. Without this, the writer + // lands the bytes at the literal lfp (no base prefix) so subsequent + // openFile(lfp, base) calls fail with "File not found". + const dst = await this.resolveFilePath(lfp, base); return CloudSvc.downloadReplicaFileFromCloud(this, { kind, replicaId, diff --git a/apps/readest-app/src/services/dictionaries/dictionaryDedup.ts b/apps/readest-app/src/services/dictionaries/dictionaryDedup.ts new file mode 100644 index 00000000..4492ac15 --- /dev/null +++ b/apps/readest-app/src/services/dictionaries/dictionaryDedup.ts @@ -0,0 +1,103 @@ +import type { ImportedDictionary } from './types'; + +/** + * Resolve which existing (non-deleted) entries the incoming bundle should + * replace. Match by contentId first (stable per file content; survives + * user-driven renames), fall back to name when either side lacks contentId + * (legacy bundles imported before the contentId field existed). + * + * Returns all matching existing entries — multiple are possible when the + * user previously imported the same file more than once. + */ +export const findExistingDictionaryMatches = ( + incoming: ImportedDictionary, + existing: ImportedDictionary[], +): ImportedDictionary[] => { + const live = existing.filter((d) => !d.deletedAt); + + if (incoming.contentId) { + const byContent = live.filter((d) => d.contentId === incoming.contentId); + if (byContent.length > 0) return byContent; + // contentId is set on the incoming side but no existing entry has it. + // Fall through to name match for legacy entries (contentId-less) that + // could correspond to the same file under a previous import. + } + + return live.filter((d) => !d.contentId && d.name === incoming.name); +}; + +/** + * When re-importing a file that matches an existing entry, keep the user's + * label. The match was by content (or by legacy name), so the parsed name + * on `incoming` is whatever the bundle's metadata says — but the user may + * have customized the label since their original import. Preserving their + * choice avoids surprising them when re-import is conceptually a content + * refresh, not a label reset. + * + * If `matches` is empty, returns `incoming` (with a fresh object identity). + * If multiple matches exist (the user previously imported the same file + * more than once and now we're collapsing them), the first match's name + * wins — arbitrary but deterministic. + */ +export const preserveUserCustomName = ( + incoming: ImportedDictionary, + matches: ImportedDictionary[], +): ImportedDictionary => { + if (matches.length === 0) return { ...incoming }; + return { ...incoming, name: matches[0]!.name }; +}; + +/** + * Preserve durable local state when a live dictionary is re-imported. + * + * The fresh import owns parsed/file-backed fields (`id`, `bundleDir`, + * `files`, `contentId`, `kind`, `lang`, unsupported status). The existing + * live entry owns user/local continuity fields: display name, original + * import time, and any sticky reincarnation token. + */ +export const preserveLiveDictionaryState = ( + incoming: ImportedDictionary, + matches: ImportedDictionary[], +): ImportedDictionary => { + if (matches.length === 0) return { ...incoming }; + const first = matches[0]!; + return { + ...incoming, + name: first.name, + addedAt: first.addedAt, + ...(first.reincarnation ? { reincarnation: first.reincarnation } : {}), + }; +}; + +/** + * Explicitly re-importing the same content is enough user intent to mint + * a revival token when the local live entry does not already have one. + * + * This covers stale-local cases: another device may have tombstoned the + * server row, while this device still has a live cache entry. The live + * replacement path would otherwise publish `reincarnation = null`, and + * remove-wins would keep the remote row hidden forever. + */ +export const shouldMintReincarnationForLiveReimport = ( + incoming: ImportedDictionary, + matches: ImportedDictionary[], +): boolean => { + if (!incoming.contentId || matches.length === 0) return false; + const first = matches[0]!; + return first.contentId === incoming.contentId && !first.reincarnation; +}; + +/** + * Find soft-deleted (tombstoned) existing entries that share the + * incoming bundle's contentId. Used by the importer to detect + * re-import-after-delete and mint a reincarnation token. Returns [] + * if the incoming dict has no contentId (legacy bundles can't + * reincarnate; they get a fresh import path). + */ +export const findTombstonedDictionaryMatches = ( + incoming: ImportedDictionary, + existing: ImportedDictionary[], +): ImportedDictionary[] => { + if (!incoming.contentId) return []; + return existing.filter((d) => d.deletedAt && d.contentId && d.contentId === incoming.contentId); +}; diff --git a/apps/readest-app/src/services/dictionaries/dictionaryService.ts b/apps/readest-app/src/services/dictionaries/dictionaryService.ts index 503b5700..0eb0c553 100644 --- a/apps/readest-app/src/services/dictionaries/dictionaryService.ts +++ b/apps/readest-app/src/services/dictionaries/dictionaryService.ts @@ -16,6 +16,14 @@ import { getFilename } from '@/utils/path'; import type { ImportedDictionary } from './types'; import { scanEntryOffsets, serializeOffsetsSidecar } from './stardictReader'; import { computeDictionaryContentId } from './contentId'; +import { v4 as uuidv4 } from 'uuid'; +import { + findExistingDictionaryMatches, + findTombstonedDictionaryMatches, + preserveLiveDictionaryState, + preserveUserCustomName, + shouldMintReincarnationForLiveReimport, +} from './dictionaryDedup'; /** GZIP magic bytes — used to detect DictZip-compressed `.dict` files. */ const GZIP_MAGIC = [0x1f, 0x8b, 0x08]; @@ -526,23 +534,19 @@ export async function importDictionaries( existingDictionaries: ImportedDictionary[] = [], ): Promise { const { bundles, orphans } = groupBundlesByStem(files); - // Index existing entries by name. Multiple entries can share a name when - // the user previously imported the same dict more than once — we replace - // them all with the single new bundle. - const existingByName = new Map(); - for (const d of existingDictionaries) { - if (d.deletedAt) continue; - const list = existingByName.get(d.name); - if (list) list.push(d); - else existingByName.set(d.name, [d]); - } + // Track all existing entries; findExistingDictionaryMatches handles the + // contentId-vs-name tier logic. Re-importing a renamed dict still matches + // because contentId is stable per file content. + const existing: ImportedDictionary[] = [...existingDictionaries]; const imported: ImportedDictionary[] = []; const replacements: { oldIds: string[]; newDict: ImportedDictionary }[] = []; - // Names already added during this single import call. A second bundle in - // the same selection that shares a name with an earlier one is dropped - // (the first wins) so we don't end up with intra-call duplicates. - const seenNames = new Set(); + // ContentIds (or, for legacy bundles without one, names) already added in + // this import call. A second bundle in the same selection that matches an + // earlier one is dropped (the first wins) so we don't end up with + // intra-call duplicates. + const seenContentIds = new Set(); + const seenLegacyNames = new Set(); for (const bundle of bundles) { let dict: ImportedDictionary; @@ -556,7 +560,11 @@ export async function importDictionaries( dict = await importSlobBundle(fs, bundle); } - if (seenNames.has(dict.name)) { + const intraCallKey = dict.contentId ?? `__name:${dict.name}`; + const isIntraCallDup = dict.contentId + ? seenContentIds.has(dict.contentId) + : seenLegacyNames.has(dict.name); + if (isIntraCallDup) { try { await fs.removeDir(dict.bundleDir, 'Dictionaries', true); } catch (err) { @@ -564,10 +572,12 @@ export async function importDictionaries( } continue; } - seenNames.add(dict.name); + if (dict.contentId) seenContentIds.add(dict.contentId); + else seenLegacyNames.add(dict.name); + void intraCallKey; - const olds = existingByName.get(dict.name); - if (olds && olds.length > 0) { + const olds = findExistingDictionaryMatches(dict, existing); + if (olds.length > 0) { for (const old of olds) { try { await fs.removeDir(old.bundleDir, 'Dictionaries', true); @@ -575,10 +585,41 @@ export async function importDictionaries( console.warn('Failed to remove replaced bundle dir', old.bundleDir, err); } } - replacements.push({ oldIds: olds.map((o) => o.id), newDict: dict }); - } else { - imported.push(dict); + // Drop matched entries from `existing` so subsequent bundles in this + // call don't double-replace them. + const oldIdSet = new Set(olds.map((o) => o.id)); + for (let i = existing.length - 1; i >= 0; i--) { + if (oldIdSet.has(existing[i]!.id)) existing.splice(i, 1); + } + // Preserve durable live-entry state across re-import while keeping + // parsed/file-backed fields from the fresh bundle. + const preserved = preserveLiveDictionaryState(dict, olds); + const newDict = shouldMintReincarnationForLiveReimport(dict, olds) + ? { ...preserved, reincarnation: uuidv4() } + : preserved; + replacements.push({ oldIds: olds.map((o) => o.id), newDict }); + continue; } + + // No live match — but check for a tombstoned (soft-deleted) entry + // with the same contentId. If found, this is a reincarnation: mint + // a fresh token so the server-side row surfaces as alive again on + // every device that pulls. + const tombstoned = findTombstonedDictionaryMatches(dict, existing); + if (tombstoned.length > 0) { + const tombstonedIdSet = new Set(tombstoned.map((o) => o.id)); + for (let i = existing.length - 1; i >= 0; i--) { + if (tombstonedIdSet.has(existing[i]!.id)) existing.splice(i, 1); + } + const reincarnatedDict = preserveUserCustomName( + { ...dict, reincarnation: uuidv4() }, + tombstoned, + ); + replacements.push({ oldIds: tombstoned.map((o) => o.id), newDict: reincarnatedDict }); + continue; + } + + imported.push(dict); } return { diff --git a/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts b/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts index 4d05bc02..c1efbfdc 100644 --- a/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts +++ b/apps/readest-app/src/services/dictionaries/providers/mdictProvider.ts @@ -297,7 +297,6 @@ function wireMdxAnchors( e.stopPropagation(); onNavigate(target); }); - continue; } } } diff --git a/apps/readest-app/src/services/dictionaries/types.ts b/apps/readest-app/src/services/dictionaries/types.ts index 7068e745..f509991d 100644 --- a/apps/readest-app/src/services/dictionaries/types.ts +++ b/apps/readest-app/src/services/dictionaries/types.ts @@ -65,6 +65,18 @@ export interface ImportedDictionary { * sync wiring treats absent contentId as "needs rehash before sync". */ contentId?: string; + /** + * Reincarnation token (uuid) minted when the user re-imports a file + * whose contentId matches a previously tombstoned replica row. Per + * remove-wins semantics, a tombstone never disappears at the merge + * level — clients interpret `reincarnation != null` as "alive again" + * (the original tombstone stays as history). Set only on the + * re-import after a delete. Also minted on explicit same-content live + * re-import when the local cache has no token, because another device + * may have tombstoned the server row while this device still sees the + * entry as live. + */ + reincarnation?: string; /** Subdirectory under `'Dictionaries'` containing this bundle's files. */ bundleDir: string; /** Filenames inside `bundleDir`. The exact set varies by `kind`. */ diff --git a/apps/readest-app/src/services/settingsService.ts b/apps/readest-app/src/services/settingsService.ts index 99b3beeb..d6f818ec 100644 --- a/apps/readest-app/src/services/settingsService.ts +++ b/apps/readest-app/src/services/settingsService.ts @@ -161,6 +161,11 @@ export async function loadSettings(ctx: Context): Promise { await saveSettings(ctx.fs, settings); } + if (!settings.replicaDeviceId) { + settings.replicaDeviceId = uuidv4(); + await saveSettings(ctx.fs, settings); + } + return settings; } diff --git a/apps/readest-app/src/services/sync/replicaBinaryUpload.ts b/apps/readest-app/src/services/sync/replicaBinaryUpload.ts new file mode 100644 index 00000000..a518c353 --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaBinaryUpload.ts @@ -0,0 +1,61 @@ +import { transferManager } from '@/services/transferManager'; +import { enumerateDictionaryFiles } from './adapters/dictionary'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; +import type { AppService } from '@/types/system'; +import type { ReplicaTransferFile } from '@/store/transferStore'; +import type { ClosableFile } from '@/utils/file'; + +/** + * Resolve the on-disk byteSize for one bundle file. Mirrors the + * bookService.getBookFileSize pattern: openFile + .size + close. + * On Tauri, openFile streams metadata; .size doesn't read the body. + */ +const resolveByteSize = async (appService: AppService, lfp: string): Promise => { + const file = await appService.openFile(lfp, 'Dictionaries'); + const size = file.size; + const closable = file as ClosableFile; + if (closable && closable.close) { + await closable.close(); + } + return size; +}; + +/** + * Queue a dictionary's binary files for upload via TransferManager. + * Reads each file's byteSize once up-front so progress reporting is + * accurate, then dispatches to the existing replica-transfer pipeline. + * + * No-op when: + * - the dictionary lacks contentId (legacy bundle, needs rehash) + * - TransferManager isn't initialized yet (pre-library mount) + * + * Caller is responsible for ordering this AFTER publishDictionaryUpsert + * so the metadata row exists before the manifest commit fires. + */ +export const queueDictionaryBinaryUpload = async ( + dict: ImportedDictionary, + appService: AppService, +): Promise => { + if (!dict.contentId) return null; + if (!transferManager.isReady()) return null; + + const enumerated = enumerateDictionaryFiles(dict); + if (enumerated.length === 0) return null; + + const files: ReplicaTransferFile[] = await Promise.all( + enumerated.map(async (f) => ({ + logical: f.logical, + lfp: f.lfp, + byteSize: await resolveByteSize(appService, f.lfp), + })), + ); + + return transferManager.queueReplicaUpload( + 'dictionary', + dict.contentId, + dict.name, + files, + 'Dictionaries', + { reincarnation: dict.reincarnation }, + ); +}; diff --git a/apps/readest-app/src/services/sync/replicaCursorStore.ts b/apps/readest-app/src/services/sync/replicaCursorStore.ts new file mode 100644 index 00000000..86f0bf74 --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaCursorStore.ts @@ -0,0 +1,71 @@ +import type { AppService } from '@/types/system'; +import type { Hlc } from '@/types/replica'; +import type { CursorStore } from './replicaSyncManager'; + +export interface SettingsCursorStoreOpts { + /** Debounce window for the load-merge-save flush. Defaults to 1s. */ + debounceMs?: number; + /** Test seam — defaults to the platform setTimeout. */ + setTimeoutFn?: typeof setTimeout; + /** Test seam. */ + clearTimeoutFn?: typeof clearTimeout; +} + +/** + * Production CursorStore backed by appService.loadSettings + saveSettings. + * + * Cursors are cached in memory so get() is sync. set() debounces a save + * that does a fresh load-merge-save round-trip — this keeps us from + * clobbering fields written elsewhere in the same session (e.g., the + * library page's setSettings). + * + * Cursor advance is best-effort: a lost save just means the next pull + * re-fetches a few rows. We never block the sync flow on disk IO. + */ +export const createSettingsCursorStore = ( + appService: AppService, + opts: SettingsCursorStoreOpts = {}, +): CursorStore => { + const cache = new Map(); + const setTimeoutFn = opts.setTimeoutFn ?? setTimeout; + const clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout; + const debounceMs = opts.debounceMs ?? 1000; + let pending: ReturnType | null = null; + + void (async () => { + try { + const settings = await appService.loadSettings(); + for (const [k, v] of Object.entries(settings.lastSyncedAtReplicas ?? {})) { + cache.set(k, v as Hlc); + } + } catch (err) { + console.warn('replica cursor hydrate failed', err); + } + })(); + + const flush = async () => { + try { + const settings = await appService.loadSettings(); + settings.lastSyncedAtReplicas = Object.fromEntries(cache); + await appService.saveSettings(settings); + } catch (err) { + console.warn('replica cursor save failed', err); + } + }; + + const scheduleFlush = () => { + if (pending !== null) clearTimeoutFn(pending); + pending = setTimeoutFn(() => { + pending = null; + void flush(); + }, debounceMs); + }; + + return { + get: (kind) => cache.get(kind) ?? null, + set: (kind, hlc) => { + cache.set(kind, hlc); + scheduleFlush(); + }, + }; +}; diff --git a/apps/readest-app/src/services/sync/replicaDictionaryApply.ts b/apps/readest-app/src/services/sync/replicaDictionaryApply.ts new file mode 100644 index 00000000..d8d433a3 --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaDictionaryApply.ts @@ -0,0 +1,117 @@ +import type { FieldEnvelope, FieldsObject, Manifest, ReplicaRow } from '@/types/replica'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; + +export interface UnwrappedDictionaryFields { + name?: string; + kind?: ImportedDictionary['kind']; + lang?: string; + addedAt?: number; + unsupported?: boolean; + unsupportedReason?: string; +} + +const unwrapEnvelopeValue = (env: FieldEnvelope | undefined): unknown => + env && typeof env === 'object' && 'v' in env ? (env as FieldEnvelope).v : undefined; + +/** + * Unpack a fields_jsonb (envelope-wrapped) object into plain values for + * the dictionary kind. Mirrors dictionaryAdapter.unpack but takes the + * raw envelope shape so it can also tolerate cipher envelopes (no + * encrypted fields on dictionary today, but the helper is defensive). + */ +export const unwrapDictionaryFields = (fields: FieldsObject): UnwrappedDictionaryFields => { + const name = unwrapEnvelopeValue(fields['name']); + const kind = unwrapEnvelopeValue(fields['kind']); + const lang = unwrapEnvelopeValue(fields['lang']); + const addedAt = unwrapEnvelopeValue(fields['addedAt']); + const unsupported = unwrapEnvelopeValue(fields['unsupported']); + const unsupportedReason = unwrapEnvelopeValue(fields['unsupportedReason']); + + return { + name: typeof name === 'string' ? name : undefined, + kind: + kind === 'mdict' || kind === 'stardict' || kind === 'dict' || kind === 'slob' + ? kind + : undefined, + lang: typeof lang === 'string' ? lang : undefined, + addedAt: typeof addedAt === 'number' ? addedAt : undefined, + unsupported: unsupported === true ? true : undefined, + unsupportedReason: typeof unsupportedReason === 'string' ? unsupportedReason : undefined, + }; +}; + +/** + * Reconstruct ImportedDictionary.files from a manifest. Dispatch by + * filename extension within the bundle's declared kind. Skips + * `.idx.offsets` / `.syn.offsets` sidecars — those are device-local + * indices that don't belong in the synced manifest. + */ +export const filesFromManifest = ( + manifest: Manifest | null, + kind: ImportedDictionary['kind'], +): ImportedDictionary['files'] => { + const out: ImportedDictionary['files'] = {}; + if (!manifest) return out; + + const mdd: string[] = []; + const css: string[] = []; + + for (const f of manifest.files) { + const lower = f.filename.toLowerCase(); + if (lower.endsWith('.idx.offsets') || lower.endsWith('.syn.offsets')) continue; + + if (kind === 'mdict') { + if (lower.endsWith('.mdx')) out.mdx = f.filename; + else if (lower.endsWith('.mdd')) mdd.push(f.filename); + else if (lower.endsWith('.css')) css.push(f.filename); + } else if (kind === 'stardict') { + if (lower.endsWith('.ifo')) out.ifo = f.filename; + else if (lower.endsWith('.idx')) out.idx = f.filename; + else if (lower.endsWith('.syn')) out.syn = f.filename; + else if (lower.endsWith('.dict.dz') || lower.endsWith('.dict')) out.dict = f.filename; + } else if (kind === 'dict') { + if (lower.endsWith('.index')) out.index = f.filename; + else if (lower.endsWith('.dict.dz') || lower.endsWith('.dict')) out.dict = f.filename; + } else if (kind === 'slob') { + if (lower.endsWith('.slob')) out.slob = f.filename; + } + } + + if (mdd.length > 0) out.mdd = mdd; + if (css.length > 0) out.css = css; + return out; +}; + +/** + * Build a local ImportedDictionary placeholder from a pulled replica row. + * The placeholder is marked `unavailable: true` until the binary + * download completes — at which point a download-completion handler + * clears the flag. + * + * Returns null when the row's fields_jsonb is malformed (missing + * required name or kind). The pull orchestrator skips null returns. + */ +export const buildLocalDictFromRow = ( + row: ReplicaRow, + bundleDir: string, +): ImportedDictionary | null => { + const fields = unwrapDictionaryFields(row.fields_jsonb); + if (!fields.name || !fields.kind) return null; + + const dict: ImportedDictionary = { + id: bundleDir, + contentId: row.replica_id, + kind: fields.kind, + name: fields.name, + bundleDir, + files: filesFromManifest(row.manifest_jsonb, fields.kind), + addedAt: fields.addedAt ?? Date.now(), + unavailable: true, + }; + if (fields.lang !== undefined) dict.lang = fields.lang; + if (fields.unsupported) dict.unsupported = true; + if (fields.unsupportedReason) dict.unsupportedReason = fields.unsupportedReason; + if (row.reincarnation) dict.reincarnation = row.reincarnation; + + return dict; +}; diff --git a/apps/readest-app/src/services/sync/replicaPublish.ts b/apps/readest-app/src/services/sync/replicaPublish.ts new file mode 100644 index 00000000..04858455 --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaPublish.ts @@ -0,0 +1,116 @@ +import { setField, removeReplica, hlcMax } from '@/libs/crdt'; +import { getUserID } from '@/utils/access'; +import { getReplicaSync } from './replicaSync'; +import { + DICTIONARY_KIND, + DICTIONARY_SCHEMA_VERSION, + dictionaryAdapter, +} from './adapters/dictionary'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; +import type { FieldsObject, Hlc, ReplicaRow } from '@/types/replica'; + +/** + * Build + push a CRDT row for an upsert of a single dictionary. Each + * field gets a fresh HLC stamp; updated_at_ts is the max of all field + * stamps. The row is queued via replicaSyncManager.markDirty (5s + * debounced push, immediate flush on visibilitychange / online). + * + * No-op when: + * - replica sync is not initialized (e.g., user signed out) + * - the dictionary lacks contentId (legacy bundle, needs rehash before sync) + * - the user is not authenticated + */ +export const publishDictionaryUpsert = async (dict: ImportedDictionary): Promise => { + const ctx = getReplicaSync(); + if (!ctx) return; + if (!dict.contentId) return; + const userId = await getUserID(); + if (!userId) return; + + const packed = dictionaryAdapter.pack(dict); + let fields: FieldsObject = {}; + let maxFieldHlc: Hlc | null = null; + for (const [key, value] of Object.entries(packed)) { + const t = ctx.hlc.next(); + fields = setField(fields, key, value, t, ctx.deviceId); + maxFieldHlc = hlcMax(maxFieldHlc, t); + } + + const updatedAt = maxFieldHlc ?? ctx.hlc.next(); + + const row: ReplicaRow = { + user_id: userId, + kind: DICTIONARY_KIND, + replica_id: dict.contentId, + fields_jsonb: fields, + manifest_jsonb: null, + deleted_at_ts: null, + reincarnation: dict.reincarnation ?? null, + updated_at_ts: updatedAt, + schema_version: DICTIONARY_SCHEMA_VERSION, + }; + ctx.manager.markDirty(row); +}; + +/** + * Tombstone a dictionary row by contentId. The row carries no fields — + * just the deleted_at_ts HLC. Per remove-wins semantics, a later field + * write does NOT revive this row; only an explicit reincarnation token + * does. + * + * No-op when replica sync isn't initialized or the user isn't authenticated. + */ +export const publishDictionaryDelete = async (contentId: string): Promise => { + const ctx = getReplicaSync(); + if (!ctx) return; + const userId = await getUserID(); + if (!userId) return; + + const tombstoneHlc = ctx.hlc.next(); + const baseRow: ReplicaRow = { + user_id: userId, + kind: DICTIONARY_KIND, + replica_id: contentId, + fields_jsonb: {}, + manifest_jsonb: null, + deleted_at_ts: null, + reincarnation: null, + updated_at_ts: tombstoneHlc, + schema_version: DICTIONARY_SCHEMA_VERSION, + }; + ctx.manager.markDirty(removeReplica(baseRow, tombstoneHlc)); +}; + +/** + * Publish a manifest for an existing dictionary row. Called once binary + * uploads complete (transferManager fires `replica-transfer-complete`). + * The fields_jsonb is empty — server-side per-field LWW preserves the + * existing fields; only manifest_jsonb is updated. updated_at_ts = fresh + * HLC so the manifest wins over any prior null value on the row. + * + * No-ops when sync isn't initialized or the user isn't authenticated. + */ +export const publishDictionaryManifest = async ( + contentId: string, + files: { filename: string; byteSize: number; partialMd5: string }[], + reincarnation?: string, +): Promise => { + const ctx = getReplicaSync(); + if (!ctx) return; + const userId = await getUserID(); + if (!userId) return; + + const updatedAt = ctx.hlc.next(); + const row: ReplicaRow = { + user_id: userId, + kind: DICTIONARY_KIND, + replica_id: contentId, + fields_jsonb: {}, + manifest_jsonb: { files, schemaVersion: 1 }, + deleted_at_ts: null, + reincarnation: reincarnation ?? null, + updated_at_ts: updatedAt, + schema_version: DICTIONARY_SCHEMA_VERSION, + }; + ctx.manager.markDirty(row); +}; diff --git a/apps/readest-app/src/services/sync/replicaPullDictionaries.ts b/apps/readest-app/src/services/sync/replicaPullDictionaries.ts new file mode 100644 index 00000000..fb083435 --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaPullDictionaries.ts @@ -0,0 +1,145 @@ +import { isReplicaRowAlive } from '@/libs/replicaInterpret'; +import type { ReplicaRow } from '@/types/replica'; +import type { ImportedDictionary } from '@/services/dictionaries/types'; +import type { ReplicaTransferFile } from '@/store/transferStore'; +import type { BaseDir } from '@/types/system'; +import { buildLocalDictFromRow } from './replicaDictionaryApply'; + +export interface PullDictionariesDeps { + /** Pulls dictionary rows since the last cursor advance. */ + pull(): Promise; + /** Looks up an existing local dict by its cross-device contentId. */ + findByContentId(contentId: string): ImportedDictionary | undefined; + /** Adds a remote-sourced dict to the local store WITHOUT republishing. */ + applyRemoteDictionary(dict: ImportedDictionary): void; + /** + * Tombstones the local entry whose contentId matches. Implementer + * looks up by contentId, calls removeDictionary on the local store, + * but skips publishDictionaryDelete (the row is already tombstoned + * server-side; we just observed that fact). + */ + softDeleteByContentId(contentId: string): void; + /** + * Mints a fresh local bundleDir, creates the directory on disk under + * the 'Dictionaries' base dir, returns the directory name (relative). + */ + createBundleDir(): Promise; + /** + * Hands the manifest's binary files off to TransferManager for + * download. Returns the transfer id (or null if the queue isn't + * ready). Caller arguments mirror transferManager.queueReplicaDownload. + */ + queueReplicaDownload( + contentId: string, + displayTitle: string, + files: ReplicaTransferFile[], + bundleDir: string, + base: BaseDir, + ): string | null; + /** + * Returns true iff EVERY filename exists on disk under + * `/` in the kind's base dir. Lets the + * orchestrator skip the download queue when the binaries from a + * previous session are still around — refreshing the page or pulling + * a row whose contentId already maps to a populated bundle is a + * no-op rather than a re-download. + */ + filesExist(bundleDir: string, filenames: string[]): Promise; + /** + * Hydrates the persistence-aware local store from disk before the + * orchestrator queries findByContentId. Without this, applyRow's + * subsequent applyRemoteDictionary + auto-persist round-trip + * overwrites settings.customDictionaries with only the rows we just + * applied — wiping persisted entries that hadn't yet been pulled + * into the zustand store by a feature mount. + */ + hydrateLocalStore?(): Promise; + /** + * Optional auth precheck. When provided and resolves to false, the + * orchestrator skips the entire pull (no network call, no warnings). + * Lets the boot site avoid spamming "Not authenticated" errors when + * the user is signed out but a prior session left a deviceId behind. + */ + isAuthenticated?(): Promise; +} + +const MANIFEST_FILE_TO_TRANSFER = ( + filename: string, + byteSize: number, + bundleDir: string, +): ReplicaTransferFile => ({ + logical: filename, + // Local file path under the 'Dictionaries' base — TransferManager + // resolves it via appService for the actual download IO. + lfp: `${bundleDir}/${filename}`, + byteSize, +}); + +const applyRow = async (row: ReplicaRow, deps: PullDictionariesDeps): Promise => { + const local = deps.findByContentId(row.replica_id); + const alive = isReplicaRowAlive(row); + + if (!alive) { + if (local && !local.deletedAt) { + deps.softDeleteByContentId(row.replica_id); + } + return; + } + + // Decide bundleDir + display name. If a local entry already maps this + // contentId, reuse its bundleDir so we don't orphan the previously + // downloaded binaries; otherwise mint a fresh dir and apply the remote + // dict to the local store. + let bundleDir: string; + let displayName: string; + if (local) { + bundleDir = local.bundleDir; + displayName = local.name; + } else { + bundleDir = await deps.createBundleDir(); + const dict = buildLocalDictFromRow(row, bundleDir); + if (!dict) return; + deps.applyRemoteDictionary(dict); + displayName = dict.name; + } + + if (!row.manifest_jsonb || row.manifest_jsonb.files.length === 0) return; + + // Skip the download queue if every manifest file is already on disk + // under the resolved bundle dir. Refresh-the-page is a no-op rather + // than a re-download; partial-download recovery still queues because + // some files would be missing. + const filenames = row.manifest_jsonb.files.map((f) => f.filename); + const allPresent = await deps.filesExist(bundleDir, filenames); + if (allPresent) return; + + const files = row.manifest_jsonb.files.map((f) => + MANIFEST_FILE_TO_TRANSFER(f.filename, f.byteSize, bundleDir), + ); + deps.queueReplicaDownload(row.replica_id, displayName, files, bundleDir, 'Dictionaries'); +}; + +/** + * Pull-side dispatcher: walks rows since the last cursor advance and + * applies each via applyRow. Errors per row are isolated — one bad + * row never blocks the others. + */ +export const pullDictionariesAndApply = async (deps: PullDictionariesDeps): Promise => { + if (deps.isAuthenticated && !(await deps.isAuthenticated())) return; + // Hydrate the in-memory dict store from disk BEFORE the apply loop. + // applyRemoteDictionary auto-persists the in-memory list back to + // settings, so if the in-memory list isn't already populated, the + // first save would clobber every persisted dict that we hadn't + // re-read into memory. + if (deps.hydrateLocalStore) { + await deps.hydrateLocalStore(); + } + const rows = await deps.pull(); + for (const row of rows) { + try { + await applyRow(row, deps); + } catch (err) { + console.warn('replica pull row apply failed', { replicaId: row.replica_id, err }); + } + } +}; diff --git a/apps/readest-app/src/services/sync/replicaSyncManager.ts b/apps/readest-app/src/services/sync/replicaSyncManager.ts index 7c61f411..7d43048f 100644 --- a/apps/readest-app/src/services/sync/replicaSyncManager.ts +++ b/apps/readest-app/src/services/sync/replicaSyncManager.ts @@ -1,4 +1,4 @@ -import { HlcGenerator, hlcCompare } from '@/libs/crdt'; +import { HlcGenerator, hlcCompare, hlcMax, mergeFields } from '@/libs/crdt'; import type { Hlc, ReplicaRow } from '@/types/replica'; import type { ReplicaSyncClient } from '@/libs/replicaSyncClient'; @@ -25,6 +25,50 @@ const splitKey = (k: string): DirtyKey => { return { kind: k.slice(0, idx), replicaId: k.slice(idx + 2) }; }; +const mergeDirtyRows = (a: ReplicaRow, b: ReplicaRow): ReplicaRow => { + if (a.user_id !== b.user_id || a.kind !== b.kind || a.replica_id !== b.replica_id) { + throw new Error('mergeDirtyRows: identity mismatch'); + } + + const fields_jsonb = mergeFields(a.fields_jsonb, b.fields_jsonb); + const deleted_at_ts = hlcMax(a.deleted_at_ts, b.deleted_at_ts); + + const reincarnationCandidates = [ + a.reincarnation ? { token: a.reincarnation, t: a.updated_at_ts } : null, + b.reincarnation ? { token: b.reincarnation, t: b.updated_at_ts } : null, + ].filter((c): c is { token: string; t: Hlc } => c !== null); + const winningReincarnation = + reincarnationCandidates.length === 0 + ? null + : reincarnationCandidates.reduce((x, y) => (hlcCompare(x.t, y.t) >= 0 ? x : y)); + const reincarnation = + winningReincarnation && + (!deleted_at_ts || hlcCompare(winningReincarnation.t, deleted_at_ts) > 0) + ? winningReincarnation.token + : null; + + const manifest_jsonb = + b.manifest_jsonb === null + ? a.manifest_jsonb + : a.manifest_jsonb === null + ? b.manifest_jsonb + : hlcCompare(b.updated_at_ts, a.updated_at_ts) > 0 + ? b.manifest_jsonb + : a.manifest_jsonb; + + return { + user_id: a.user_id, + kind: a.kind, + replica_id: a.replica_id, + fields_jsonb, + manifest_jsonb, + deleted_at_ts, + reincarnation, + updated_at_ts: hlcMax(a.updated_at_ts, b.updated_at_ts) ?? a.updated_at_ts, + schema_version: Math.max(a.schema_version, b.schema_version), + }; +}; + export class ReplicaSyncManager { private readonly dirty = new Map(); private readonly debounceMs: number; @@ -44,7 +88,9 @@ export class ReplicaSyncManager { } markDirty(row: ReplicaRow): void { - this.dirty.set(dirtyKeyOf(row), row); + const key = dirtyKeyOf(row); + const existing = this.dirty.get(key); + this.dirty.set(key, existing ? mergeDirtyRows(existing, row) : row); this.scheduleDebouncedFlush(); } @@ -77,8 +123,13 @@ export class ReplicaSyncManager { } } - async pull(kind: string): Promise { - const since = this.opts.cursorStore.get(kind); + async pull(kind: string, opts?: { since?: Hlc | null }): Promise { + // The boot orchestrator passes `{ since: null }` to do a full pull + // that ignores the persisted cursor — this lets us recover when a + // previous boot advanced the cursor past rows that never made it + // into the local store (e.g., apply-without-persist bug). Periodic + // sync (visibility / online) keeps using the cursor. + const since = opts && 'since' in opts ? (opts.since ?? null) : this.opts.cursorStore.get(kind); const rows = await this.opts.client.pull(kind, since); if (rows.length === 0) return rows; let maxHlc: Hlc = rows[0]!.updated_at_ts; diff --git a/apps/readest-app/src/services/sync/replicaTransferIntegration.ts b/apps/readest-app/src/services/sync/replicaTransferIntegration.ts new file mode 100644 index 00000000..6947375e --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaTransferIntegration.ts @@ -0,0 +1,91 @@ +import { eventDispatcher } from '@/utils/event'; +import { partialMD5 } from '@/utils/md5'; +import { useCustomDictionaryStore } from '@/store/customDictionaryStore'; +import { publishDictionaryManifest } from './replicaPublish'; +import type { AppService } from '@/types/system'; +import type { ClosableFile } from '@/utils/file'; +import type { ReplicaTransferFile } from '@/store/transferStore'; + +interface ReplicaTransferCompleteDetail { + kind: string; + replicaId: string; + reincarnation?: string; + type: 'upload' | 'download' | 'delete'; + files?: ReplicaTransferFile[]; + filenames?: string[]; +} + +let started = false; +let appServiceRef: AppService | null = null; +let listener: ((event: CustomEvent) => Promise) | null = null; + +const handleReplicaUpload = async (detail: ReplicaTransferCompleteDetail): Promise => { + if (!detail.files || detail.files.length === 0) return; + if (!appServiceRef) return; + + try { + const manifestFiles = await Promise.all( + detail.files.map(async (f) => { + const file = await appServiceRef!.openFile(f.lfp, 'Dictionaries'); + const partialMd5 = await partialMD5(file); + const closable = file as ClosableFile; + if (closable && closable.close) await closable.close(); + return { filename: f.logical, byteSize: f.byteSize, partialMd5 }; + }), + ); + await publishDictionaryManifest(detail.replicaId, manifestFiles, detail.reincarnation); + } catch (err) { + console.warn('replica-transfer-complete upload handler failed', err); + } +}; + +const handleReplicaDownload = (detail: ReplicaTransferCompleteDetail): void => { + // The pull orchestrator created the local dict with unavailable=true + // as a placeholder. Now that the binaries are on disk, clear the flag + // so the provider registry surfaces the dict for lookups. + try { + useCustomDictionaryStore.getState().markAvailableByContentId(detail.replicaId); + } catch (err) { + console.warn('replica-transfer-complete download handler failed', err); + } +}; + +const handleReplicaTransferComplete = async (event: CustomEvent): Promise => { + const detail = event.detail as ReplicaTransferCompleteDetail | undefined; + if (!detail) return; + if (detail.kind !== 'dictionary') return; + + if (detail.type === 'upload') { + await handleReplicaUpload(detail); + } else if (detail.type === 'download') { + handleReplicaDownload(detail); + } + // 'delete' is fire-and-forget; no follow-up needed. +}; + +/** + * Wires the long-lived `replica-transfer-complete` listener that turns + * a finished binary upload into a manifest commit (per the upload state + * machine: binaries first, manifest LAST). Called once from EnvContext + * after appService boots; idempotent. + * + * Callers that subsequently sign in / out shouldn't re-call this — the + * listener doesn't need to know auth state, and publishDictionaryManifest + * already gates on the user being authenticated. + */ +export const startReplicaTransferIntegration = (appService: AppService): void => { + appServiceRef = appService; + if (started) return; + started = true; + listener = handleReplicaTransferComplete; + eventDispatcher.on('replica-transfer-complete', listener); +}; + +export const __resetReplicaTransferIntegrationForTests = (): void => { + if (listener) { + eventDispatcher.off('replica-transfer-complete', listener); + listener = null; + } + started = false; + appServiceRef = null; +}; diff --git a/apps/readest-app/src/services/transferManager.ts b/apps/readest-app/src/services/transferManager.ts index c4654f6d..ffcbd3d0 100644 --- a/apps/readest-app/src/services/transferManager.ts +++ b/apps/readest-app/src/services/transferManager.ts @@ -4,6 +4,7 @@ import { useTransferStore, TransferItem, ReplicaTransferFile } from '@/store/tra import { TranslationFunc } from '@/hooks/useTranslation'; import { ProgressHandler, ProgressPayload } from '@/utils/transfer'; import { eventDispatcher } from '@/utils/event'; +import { getTransferMessages } from './transferMessages'; const TRANSFER_QUEUE_KEY = 'readest_transfer_queue'; const RETRY_DELAY_BASE_MS = 2000; @@ -22,6 +23,10 @@ class TransferManager { private getLibrary: (() => Book[]) | null = null; private updateBook: ((book: Book) => Promise) | null = null; private _: TranslationFunc | null = null; + private readyResolve: () => void = () => {}; + private readyPromise: Promise = new Promise((resolve) => { + this.readyResolve = resolve; + }); private constructor() {} @@ -46,6 +51,7 @@ class TransferManager { this._ = translationFn; await this.loadPersistedQueue(); this.isInitialized = true; + this.readyResolve(); // Start processing queue this.processQueue(); @@ -55,6 +61,16 @@ class TransferManager { return this.isInitialized && this.appService !== null; } + /** + * Resolves once `initialize()` has completed. Lets callers that need + * to enqueue transfers (e.g., the boot-time replica pull) defer until + * the manager is wired up — the manager only inits after the library + * is loaded, which can lag well behind app boot. + */ + waitUntilReady(): Promise { + return this.readyPromise; + } + queueUpload(book: Book, priority: number = 10): string | null { if (!this.isReady()) { console.warn('TransferManager not initialized'); @@ -125,7 +141,7 @@ class TransferManager { displayTitle: string, files: ReplicaTransferFile[], base: BaseDir, - opts: { priority?: number; isBackground?: boolean } = {}, + opts: { priority?: number; isBackground?: boolean; reincarnation?: string } = {}, ): string | null { if (!this.isReady()) { console.warn('TransferManager not initialized'); @@ -140,6 +156,7 @@ class TransferManager { isBackground: opts.isBackground, files, base, + reincarnation: opts.reincarnation, }); this.persistQueue(); this.processQueue(); @@ -317,17 +334,13 @@ class TransferManager { useTransferStore.getState().setTransferStatus(transfer.id, 'completed'); - const successMessages = { - upload: _('Book uploaded: {{title}}', { title: transfer.bookTitle }), - download: _('Book downloaded: {{title}}', { title: transfer.bookTitle }), - delete: _('Deleted cloud backup of the book: {{title}}', { title: transfer.bookTitle }), - }; + const messages = getTransferMessages(transfer, _); if (!transfer.isBackground) { eventDispatcher.dispatch('toast', { type: 'info', timeout: 2000, - message: successMessages[transfer.type], + message: messages.success[transfer.type], }); } } catch (error) { @@ -365,13 +378,7 @@ class TransferManager { message: _('Insufficient storage quota'), }); } else { - const errorMessages = { - upload: _('Failed to upload book: {{title}}', { title: transfer.bookTitle }), - download: _('Failed to download book: {{title}}', { title: transfer.bookTitle }), - delete: _('Failed to delete cloud backup of the book: {{title}}', { - title: transfer.bookTitle, - }), - }; + const errorMessages = getTransferMessages(transfer, _).failure; eventDispatcher.dispatch('toast', { type: 'error', @@ -478,6 +485,7 @@ class TransferManager { eventDispatcher.dispatch('replica-transfer-complete', { kind, replicaId, + reincarnation: transfer.replicaReincarnation, type: 'upload', files, }); @@ -485,12 +493,14 @@ class TransferManager { } if (transfer.type === 'download') { + const base = transfer.replicaBase!; for (const file of files) { await this.appService!.downloadReplicaFile( kind, replicaId, file.logical, file.lfp, + base, fileProgressHandler(file.byteSize), ); bytesAlreadyDone += file.byteSize; diff --git a/apps/readest-app/src/services/transferMessages.ts b/apps/readest-app/src/services/transferMessages.ts new file mode 100644 index 00000000..cce9e4de --- /dev/null +++ b/apps/readest-app/src/services/transferMessages.ts @@ -0,0 +1,50 @@ +import type { TransferItem } from '@/store/transferStore'; +import type { TranslationFunc } from '@/hooks/useTranslation'; + +export interface TransferMessages { + success: { upload: string; download: string; delete: string }; + failure: { upload: string; download: string; delete: string }; +} + +/** + * Build per-kind toast copy for a TransferItem. Books and replica kinds + * (currently just `dictionary`; fonts / textures / OPDS catalogs come + * later) get distinct strings so the toast doesn't say "Book uploaded" + * when a dictionary just synced. + * + * Future replica kinds slot into the switch on transfer.replicaKind. + */ +export const getTransferMessages = ( + transfer: TransferItem, + _: TranslationFunc, +): TransferMessages => { + const title = transfer.bookTitle; + + if (transfer.kind === 'replica' && transfer.replicaKind === 'dictionary') { + return { + success: { + upload: _('Dictionary uploaded: {{title}}', { title }), + download: _('Dictionary downloaded: {{title}}', { title }), + delete: _('Deleted cloud copy of the dictionary: {{title}}', { title }), + }, + failure: { + upload: _('Failed to upload dictionary: {{title}}', { title }), + download: _('Failed to download dictionary: {{title}}', { title }), + delete: _('Failed to delete cloud copy of the dictionary: {{title}}', { title }), + }, + }; + } + + return { + success: { + upload: _('Book uploaded: {{title}}', { title }), + download: _('Book downloaded: {{title}}', { title }), + delete: _('Deleted cloud backup of the book: {{title}}', { title }), + }, + failure: { + upload: _('Failed to upload book: {{title}}', { title }), + download: _('Failed to download book: {{title}}', { title }), + delete: _('Failed to delete cloud backup of the book: {{title}}', { title }), + }, + }; +}; diff --git a/apps/readest-app/src/store/customDictionaryStore.ts b/apps/readest-app/src/store/customDictionaryStore.ts index 6e19212c..876397b5 100644 --- a/apps/readest-app/src/store/customDictionaryStore.ts +++ b/apps/readest-app/src/store/customDictionaryStore.ts @@ -7,6 +7,7 @@ import type { } from '@/services/dictionaries/types'; import { BUILTIN_PROVIDER_IDS, BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types'; import { useSettingsStore } from './settingsStore'; +import { publishDictionaryDelete, publishDictionaryUpsert } from '@/services/sync/replicaPublish'; /** * Built-in web-search ids are seeded into `providerOrder` but disabled by @@ -47,6 +48,33 @@ interface DictionaryStoreState { /** Add (or revive) an imported dictionary. New entries are appended to providerOrder + enabled. */ addDictionary(dict: ImportedDictionary): void; + /** + * Add a dictionary received via replica sync from another device. Same + * effect on local state as addDictionary, but does NOT call + * publishDictionaryUpsert — the row already exists on the server (we + * just pulled it). Re-publishing would create a tight feedback loop + * with stale HLCs. Used exclusively by the pull-side orchestrator. + */ + applyRemoteDictionary(dict: ImportedDictionary): void; + /** + * Look up a local imported entry by its cross-device contentId. Used + * by the pull-side orchestrator to detect "row from another device" + * vs "row originated here" cases. + */ + findByContentId(contentId: string): ImportedDictionary | undefined; + /** + * Clears the `unavailable` flag on the dict matching `contentId`. Called + * by the replica-transfer-complete listener after a remote-sourced dict + * finishes downloading from cloud storage. No-op if the dict isn't found. + */ + markAvailableByContentId(contentId: string): void; + /** + * Soft-delete by contentId, skipping the publishDictionaryDelete call + * that removeDictionary does. Used by the pull orchestrator when a + * server row arrives tombstoned — the row is already deleted on the + * server; we just observed it and need to mirror locally. + */ + softDeleteByContentId(contentId: string): void; /** * Patch an imported dictionary's mutable display fields (currently just * `name`). The on-disk bundle is untouched. No-op if the id is unknown @@ -90,6 +118,35 @@ function toSettingsDict(dict: ImportedDictionary): ImportedDictionary { return rest; } +// Replica-side mutators (applyRemoteDictionary, softDeleteByContentId, +// markAvailableByContentId) fire from boot-time pull / download-complete +// handlers, NOT the settings UI. The UI couples its state mutations with +// an explicit saveCustomDictionaries(envConfig) call; the replica path +// has no such pairing, so without this auto-persist the next +// loadCustomDictionaries (run on Annotator / settings panel mount) reads +// stale settings.customDictionaries and wipes the in-memory rows. +let replicaPersistEnv: EnvConfigType | null = null; +export const enableReplicaAutoPersist = (envConfig: EnvConfigType | null): void => { + replicaPersistEnv = envConfig; +}; + +/** + * Look up a dict by its cross-device contentId, falling back to the + * persisted `settings.customDictionaries` when the in-memory store is + * empty. The pull-side orchestrator runs at app boot — earlier than + * Annotator/CustomDictionaries mount, so loadCustomDictionaries hasn't + * hydrated the zustand store yet. Without the fallback every refresh + * looks like a brand-new device, mints a fresh bundleDir per row, and + * re-downloads all binaries. + */ +export const findDictionaryByContentId = (contentId: string): ImportedDictionary | undefined => { + if (!contentId) return undefined; + const inMemory = useCustomDictionaryStore.getState().findByContentId(contentId); + if (inMemory) return inMemory; + const persisted = useSettingsStore.getState().settings?.customDictionaries ?? []; + return persisted.find((d) => d.contentId === contentId && !d.deletedAt); +}; + export const useCustomDictionaryStore = create((set, get) => ({ dictionaries: [], settings: { ...DEFAULT_DICTIONARY_SETTINGS }, @@ -120,9 +177,65 @@ export const useCustomDictionaryStore = create((set, get) settings: { ...state.settings, providerOrder: order, providerEnabled: enabled }, }; }); + void publishDictionaryUpsert(dict); + }, + + applyRemoteDictionary: (dict) => { + // Same local-state mutation as addDictionary, minus the publish call. + // The row already exists on the server (we just pulled it). + set((state) => { + const existingIdx = state.dictionaries.findIndex((d) => d.id === dict.id); + const dictionaries = + existingIdx >= 0 + ? state.dictionaries.map((d, i) => + i === existingIdx ? { ...dict, deletedAt: undefined } : d, + ) + : [...state.dictionaries, dict]; + const order = state.settings.providerOrder.includes(dict.id) + ? state.settings.providerOrder + : [...state.settings.providerOrder, dict.id]; + const enabled = { ...state.settings.providerEnabled }; + if (!(dict.id in enabled)) enabled[dict.id] = !dict.unsupported; + return { + dictionaries, + settings: { ...state.settings, providerOrder: order, providerEnabled: enabled }, + }; + }); + if (replicaPersistEnv) void get().saveCustomDictionaries(replicaPersistEnv); + }, + + findByContentId: (contentId) => + contentId ? get().dictionaries.find((d) => d.contentId === contentId) : undefined, + + markAvailableByContentId: (contentId) => { + set((state) => ({ + dictionaries: state.dictionaries.map((d) => + d.contentId === contentId ? { ...d, unavailable: undefined } : d, + ), + })); + if (replicaPersistEnv) void get().saveCustomDictionaries(replicaPersistEnv); + }, + + softDeleteByContentId: (contentId) => { + const target = get().dictionaries.find((d) => d.contentId === contentId && !d.deletedAt); + if (!target) return; + set((state) => ({ + dictionaries: state.dictionaries.map((d) => + d.id === target.id ? { ...d, deletedAt: Date.now() } : d, + ), + settings: { + ...state.settings, + providerOrder: state.settings.providerOrder.filter((p) => p !== target.id), + providerEnabled: Object.fromEntries( + Object.entries(state.settings.providerEnabled).filter(([k]) => k !== target.id), + ), + }, + })); + if (replicaPersistEnv) void get().saveCustomDictionaries(replicaPersistEnv); }, updateDictionary: (id, patch) => { + let updated: ImportedDictionary | null = null; set((state) => { const idx = state.dictionaries.findIndex((d) => d.id === id); if (idx < 0) return state; @@ -132,11 +245,11 @@ export const useCustomDictionaryStore = create((set, get) // Reject undefined (no patch), empty (would clear the label), and // unchanged (no-op). if (!trimmedName || trimmedName === old.name) return state; - const dictionaries = state.dictionaries.map((d, i) => - i === idx ? { ...d, name: trimmedName } : d, - ); + updated = { ...old, name: trimmedName }; + const dictionaries = state.dictionaries.map((d, i) => (i === idx ? updated! : d)); return { dictionaries }; }); + if (updated) void publishDictionaryUpsert(updated); }, replaceDictionaries: (oldIds, newDict) => { @@ -145,6 +258,13 @@ export const useCustomDictionaryStore = create((set, get) return; } const oldIdSet = new Set(oldIds); + // Capture contentIds of replaced dicts so we can tombstone them on the + // server. Only contentId-bearing entries actually existed cross-device; + // legacy bundleDir-only ids never published, so nothing to tombstone. + const oldContentIds = get() + .dictionaries.filter((d) => oldIdSet.has(d.id)) + .map((d) => d.contentId) + .filter((id): id is string => Boolean(id)); set((state) => { // Drop all old entries (hard-remove since the disk bundles are gone) // and append the new one. Soft-delete isn't needed: the previously @@ -185,6 +305,19 @@ export const useCustomDictionaryStore = create((set, get) settings: { ...state.settings, providerOrder, providerEnabled }, }; }); + // When reincarnating (re-import after delete), the server-side row is + // already tombstoned — re-publishing the tombstone is redundant. The + // upsert below carries a reincarnation token so clients see the row + // as alive again. For non-reincarnation replacements (re-import of a + // still-live entry, importer collapsing duplicate names), we skip + // tombstoning if the contentId is preserved across the swap (same + // content → same row → no need to delete then immediately recreate). + const isContentSurvivingSwap = + Boolean(newDict.contentId) && oldContentIds.includes(newDict.contentId!); + if (!isContentSurvivingSwap) { + for (const contentId of oldContentIds) void publishDictionaryDelete(contentId); + } + void publishDictionaryUpsert(newDict); }, removeDictionary: (id) => { @@ -202,6 +335,7 @@ export const useCustomDictionaryStore = create((set, get) ), }, })); + if (dict.contentId) void publishDictionaryDelete(dict.contentId); return true; }, diff --git a/apps/readest-app/src/store/transferStore.ts b/apps/readest-app/src/store/transferStore.ts index 690ea4e7..0dbd3952 100644 --- a/apps/readest-app/src/store/transferStore.ts +++ b/apps/readest-app/src/store/transferStore.ts @@ -18,6 +18,7 @@ export interface TransferItem { bookTitle: string; replicaKind?: string; replicaId?: string; + replicaReincarnation?: string; replicaFiles?: ReplicaTransferFile[]; replicaBase?: BaseDir; type: TransferType; @@ -64,6 +65,7 @@ interface TransferState { isBackground?: boolean; files?: ReplicaTransferFile[]; base?: BaseDir; + reincarnation?: string; }, ) => string; removeTransfer: (transferId: string) => void; @@ -160,6 +162,7 @@ export const useTransferStore = create((set, get) => ({ bookTitle: displayTitle, replicaKind, replicaId, + replicaReincarnation: opts.reincarnation, replicaFiles: opts.files, replicaBase: opts.base, type, diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 77d1f8b8..00912941 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -139,6 +139,13 @@ export interface SystemSettings { lastSyncedAtBooks: number; lastSyncedAtConfigs: number; lastSyncedAtNotes: number; + /** + * Per-device id used as the deviceId portion of every HLC this device + * mints. Lazy-generated on first sync init via uuidv4 (mirrors + * kosync.deviceId). Independent from kosync — the two services have + * distinct identifier semantics and rotation policies. + */ + replicaDeviceId?: string; /** * Per-kind cursor for replica sync. Stores the HLC string of the last * pulled row per kind. Absent kinds pull from the beginning. diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 37f5f37a..9228d9f7 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -166,7 +166,8 @@ export interface AppService { kind: string, replicaId: string, filename: string, - dst: string, + lfp: string, + base: BaseDir, onProgress?: ProgressHandler, ): Promise; deleteReplicaBundle(kind: string, replicaId: string, filenames: string[]): Promise; diff --git a/docker/volumes/db/migrations/003_add_replicas.sql b/docker/volumes/db/migrations/003_add_replicas.sql index c6161425..03c98080 100644 --- a/docker/volumes/db/migrations/003_add_replicas.sql +++ b/docker/volumes/db/migrations/003_add_replicas.sql @@ -41,8 +41,8 @@ CREATE POLICY replica_keys_delete ON public.replica_keys -- revive a tombstoned row. -- reincarnation — explicit re-import token; swaps row to alive under a -- new logical identity. --- updated_at_ts — max(field HLCs, deleted_at_ts). Used as the pull --- cursor. +-- updated_at_ts — max(field HLCs, deleted_at_ts, row-level operation +-- HLCs such as manifest commits). Used as the pull cursor. -- schema_version — per-kind schema bump; server enforces bounds. -- ───────────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS public.replicas ( diff --git a/docker/volumes/db/migrations/004_crdt_merge_replica_fn.sql b/docker/volumes/db/migrations/004_crdt_merge_replica_fn.sql index 5fb9a001..a97dee96 100644 --- a/docker/volumes/db/migrations/004_crdt_merge_replica_fn.sql +++ b/docker/volumes/db/migrations/004_crdt_merge_replica_fn.sql @@ -79,7 +79,7 @@ END; $$; -- ───────────────────────────────────────────────────────────────────────── --- updated_at_ts = max over field HLCs and tombstone HLC. +-- Content updated_at_ts = max over field HLCs and tombstone HLC. -- ───────────────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION public.crdt_compute_updated_at(fields jsonb, deleted_at text) RETURNS text diff --git a/docker/volumes/db/migrations/005_replica_manifest_cursor_updated_at.sql b/docker/volumes/db/migrations/005_replica_manifest_cursor_updated_at.sql new file mode 100644 index 00000000..9842dc72 --- /dev/null +++ b/docker/volumes/db/migrations/005_replica_manifest_cursor_updated_at.sql @@ -0,0 +1,104 @@ +-- Migration 005: keep replica updated_at_ts as the max row-operation HLC. +-- +-- Migration 004 recomputed updated_at_ts from fields_jsonb + deleted_at_ts +-- after every conflict. That loses manifest-only operation timestamps: +-- manifest_jsonb can change, but pull cursors do not advance, so a device +-- that already pulled the metadata-only row can miss the downloadable +-- transition. Preserve the max of existing row timestamp, incoming row +-- timestamp, and content/tombstone timestamp. +-- +-- Also treat incoming manifest_jsonb = null as "no manifest update" on +-- conflict. Metadata-only rows use null, and must not clear an existing +-- committed manifest. +-- +-- Reincarnation is derived from the newest non-null token candidate that +-- is newer than the merged tombstone. Null metadata/manifest rows do not +-- clear a token; a newer tombstone clears it because no token candidate is +-- newer than that tombstone. Without this, editing a reincarnated +-- dictionary title publishes p_reincarnation = null and erases the revival +-- token. + +CREATE OR REPLACE FUNCTION public.crdt_merge_replica( + p_user_id uuid, + p_kind text, + p_replica_id text, + p_fields_jsonb jsonb, + p_manifest_jsonb jsonb, + p_deleted_at_ts text, + p_reincarnation text, + p_updated_at_ts text, + p_schema_version integer +) RETURNS public.replicas +LANGUAGE plpgsql +AS $$ +DECLARE + result public.replicas; +BEGIN + INSERT INTO public.replicas AS r ( + user_id, kind, replica_id, + fields_jsonb, manifest_jsonb, deleted_at_ts, + reincarnation, updated_at_ts, schema_version + ) VALUES ( + p_user_id, p_kind, p_replica_id, + COALESCE(p_fields_jsonb, '{}'::jsonb), + p_manifest_jsonb, p_deleted_at_ts, + p_reincarnation, p_updated_at_ts, p_schema_version + ) + ON CONFLICT (user_id, kind, replica_id) DO UPDATE SET + fields_jsonb = public.crdt_merge_fields(r.fields_jsonb, EXCLUDED.fields_jsonb), + deleted_at_ts = public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts), + reincarnation = CASE + WHEN r.reincarnation IS NULL AND EXCLUDED.reincarnation IS NULL + THEN NULL + WHEN r.reincarnation IS NOT NULL AND EXCLUDED.reincarnation IS NULL + THEN CASE + WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL + OR r.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) + THEN r.reincarnation + ELSE NULL + END + WHEN r.reincarnation IS NULL AND EXCLUDED.reincarnation IS NOT NULL + THEN CASE + WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL + OR EXCLUDED.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) + THEN EXCLUDED.reincarnation + ELSE NULL + END + WHEN EXCLUDED.updated_at_ts > r.updated_at_ts + THEN CASE + WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL + OR EXCLUDED.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) + THEN EXCLUDED.reincarnation + ELSE NULL + END + ELSE CASE + WHEN public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) IS NULL + OR r.updated_at_ts > public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) + THEN r.reincarnation + ELSE NULL + END + END, + manifest_jsonb = CASE + WHEN EXCLUDED.manifest_jsonb IS NULL + THEN r.manifest_jsonb + WHEN r.manifest_jsonb IS NULL + THEN EXCLUDED.manifest_jsonb + WHEN EXCLUDED.updated_at_ts > r.updated_at_ts + THEN EXCLUDED.manifest_jsonb + ELSE r.manifest_jsonb + END, + schema_version = GREATEST(r.schema_version, EXCLUDED.schema_version), + updated_at_ts = public.hlc_max( + public.hlc_max(r.updated_at_ts, EXCLUDED.updated_at_ts), + public.crdt_compute_updated_at( + public.crdt_merge_fields(r.fields_jsonb, EXCLUDED.fields_jsonb), + public.hlc_max(r.deleted_at_ts, EXCLUDED.deleted_at_ts) + ) + ), + modified_at = now() + RETURNING * INTO result; + RETURN result; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.crdt_merge_replica(uuid, text, text, jsonb, jsonb, text, text, text, integer) TO authenticated;