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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-07 03:39:38 +08:00
committed by GitHub
parent 3b348c8f35
commit cbdc3b8f52
79 changed files with 3929 additions and 101 deletions
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -1332,5 +1332,11 @@
"Edit Dictionary": "Lugatni tahrirlash",
"MDict bundles use .mdx files; companion .mdd and .css files are optional.": "MDict toplamlari .mdx fayllaridan foydalanadi; hamroh .mdd va .css fayllari ixtiyoriy.",
"Dictionary name": "Lugat 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 — lugat eskirgan formatdan foydalanadi. Opus, MP3 yoki WAV audioli lugatni sinab koring."
"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 — lugat eskirgan formatdan foydalanadi. Opus, MP3 yoki WAV audioli lugatni sinab koring.",
"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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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}}"
}
@@ -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
};
@@ -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({}),
}),
},
};
});
@@ -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<void>>(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();
});
});
@@ -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');
});
});
@@ -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> = {}): 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,
);
});
});
@@ -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> = {}): 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);
});
});
@@ -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<typeof vi.fn>;
const mockQueueReplicaUpload = transferManager.queueReplicaUpload as ReturnType<typeof vi.fn>;
const makeFakeAppService = (sizes: Record<string, number>) => ({
openFile: vi.fn(async (path: string) => ({
size: sizes[path] ?? 0,
name: path,
})),
});
const baseDict = (overrides: Partial<ImportedDictionary> = {}): 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();
});
});
@@ -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<SystemSettings> = {}) => {
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<SystemSettings>);
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');
});
});
@@ -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> = {}): 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<string, unknown>)['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<string, unknown>)['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<string, unknown>)['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<string, unknown>)['addedAt'];
const before = Date.now();
const dict = buildLocalDictFromRow(row, 'b')!;
const after = Date.now();
expect(dict.addedAt).toBeGreaterThanOrEqual(before);
expect(dict.addedAt).toBeLessThanOrEqual(after);
});
});
@@ -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> = {}): 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<typeof vi.fn>).mockReturnValue(null);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryUpsert(baseDict());
});
test('no-ops when contentId is absent (legacy bundle)', async () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(null);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryDelete('content-hash-abc');
});
test('no-ops when user not authenticated', async () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(null);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryManifest('content-hash-abc', []);
});
test('no-ops when user not authenticated', async () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).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);
@@ -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> = {}): 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> = {}): 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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await pullDictionariesAndApply(deps);
expect(deps.createBundleDir).toHaveBeenCalledOnce();
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
const applied = (deps.applyRemoteDictionary as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(local);
(deps.filesExist as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
(deps.filesExist as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await pullDictionariesAndApply(deps);
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
const applied = (deps.applyRemoteDictionary as ReturnType<typeof vi.fn>).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<string, unknown>)['kind'];
const deps = makeDeps();
(deps.pull as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([r1, r2]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
let bundleCounter = 0;
(deps.createBundleDir as ReturnType<typeof vi.fn>).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<string, unknown>)['kind'];
const deps = makeDeps();
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([badRow, goodRow]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await pullDictionariesAndApply(deps);
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
});
});
@@ -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);
});
});
@@ -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<typeof vi.fn>;
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();
});
});
@@ -64,6 +64,12 @@ const resetTransferManager = () => {
mgr['updateBook'] = null;
mgr['_'] = null;
(mgr['abortControllers'] as Map<string, AbortController>).clear();
// Re-arm the readyPromise so each test starts with a pending one.
let resolveReady: () => void = () => {};
mgr['readyPromise'] = new Promise<void>((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);
@@ -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> = {}): 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');
});
});
@@ -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> = {}): 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',
});
});
});
@@ -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',
);
@@ -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);
@@ -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<CustomDictionariesProps> = ({ 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);
+21 -1
View File
@@ -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();
@@ -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<ReplicaKind>();
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<void> => {
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();
};
+3 -3
View File
@@ -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:
+23 -13
View File
@@ -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,
@@ -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;
};
+8 -1
View File
@@ -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 `<bundleDir>/<filename>` 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,
@@ -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);
};
@@ -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<ImportDictionariesResult> {
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<string, ImportedDictionary[]>();
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<string>();
// 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<string>();
const seenLegacyNames = new Set<string>();
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 {
@@ -297,7 +297,6 @@ function wireMdxAnchors(
e.stopPropagation();
onNavigate(target);
});
continue;
}
}
}
@@ -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`. */
@@ -161,6 +161,11 @@ export async function loadSettings(ctx: Context): Promise<SystemSettings> {
await saveSettings(ctx.fs, settings);
}
if (!settings.replicaDeviceId) {
settings.replicaDeviceId = uuidv4();
await saveSettings(ctx.fs, settings);
}
return settings;
}
@@ -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<number> => {
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<string | null> => {
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 },
);
};
@@ -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<string, Hlc>();
const setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
const clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
const debounceMs = opts.debounceMs ?? 1000;
let pending: ReturnType<typeof setTimeout> | 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();
},
};
};
@@ -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;
};
@@ -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<void> => {
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<void> => {
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<void> => {
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);
};
@@ -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<ReplicaRow[]>;
/** 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<string>;
/**
* 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
* `<bundleDir>/<filename>` 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<boolean>;
/**
* 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<void>;
/**
* 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<boolean>;
}
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<void> => {
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<void> => {
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 });
}
}
};
@@ -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<string, ReplicaRow>();
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<ReplicaRow[]> {
const since = this.opts.cursorStore.get(kind);
async pull(kind: string, opts?: { since?: Hlc | null }): Promise<ReplicaRow[]> {
// 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;
@@ -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<void>) | null = null;
const handleReplicaUpload = async (detail: ReplicaTransferCompleteDetail): Promise<void> => {
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<void> => {
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;
};
@@ -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<void>) | null = null;
private _: TranslationFunc | null = null;
private readyResolve: () => void = () => {};
private readyPromise: Promise<void> = new Promise<void>((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<void> {
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;
@@ -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 }),
},
};
};
@@ -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<DictionaryStoreState>((set, get) => ({
dictionaries: [],
settings: { ...DEFAULT_DICTIONARY_SETTINGS },
@@ -120,9 +177,65 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((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<DictionaryStoreState>((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<DictionaryStoreState>((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<DictionaryStoreState>((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<DictionaryStoreState>((set, get)
),
},
}));
if (dict.contentId) void publishDictionaryDelete(dict.contentId);
return true;
},
@@ -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<TransferState>((set, get) => ({
bookTitle: displayTitle,
replicaKind,
replicaId,
replicaReincarnation: opts.reincarnation,
replicaFiles: opts.files,
replicaBase: opts.base,
type,
+7
View File
@@ -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.
+2 -1
View File
@@ -166,7 +166,8 @@ export interface AppService {
kind: string,
replicaId: string,
filename: string,
dst: string,
lfp: string,
base: BaseDir,
onProgress?: ProgressHandler,
): Promise<void>;
deleteReplicaBundle(kind: string, replicaId: string, filenames: string[]): Promise<void>;
@@ -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 (
@@ -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
@@ -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;