From cd3a53f5077f2dc607804c5a272128c8e287a016 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 25 Jun 2026 12:50:34 +0800 Subject: [PATCH] fix(sync): WebDAV Sync now pulls latest book metadata and merges config (#4756) (#4776) * fix(sync): pull newer WebDAV book metadata to devices that already hold the book (#4756) syncLibrary only pulled title/author/cover for books missing from the local library. For a book a device already held it only pushed, so a peer's metadata edit never propagated back, and the final library.json re-push clobbered the peer's newer metadata with this device's stale copy. Add a last-writer-wins reconciliation pass keyed on book.updatedAt: when the shared index has a strictly newer copy of a locally-held book, merge its metadata, re-pull the cover, persist it via a new updateBookMetadata callback, and keep the merged copy authoritative for the index re-push so neither direction loses the edit. Surface a "metadata updated" counter in the sync toast and history, and translate the new strings across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sync): merge remote config before pushing in WebDAV Sync now (#4756) The manual library "Sync now" pushed each book's config.json blind, so it could overwrite a peer's booknotes (element-set CRDT) or regress newer remote progress (per-config LWW) that this device had not pulled yet. The reader hook already pull-merges before pushing; the library path did not, so notes and progress could diverge or regress on the remote until a device happened to open the book. Give syncLibrary's config push the same read-merge-write cycle: pull-merge then push the merged superset, persisting it locally so the device converges too. Gated on canPull so 'silent' converges while 'send' keeps the local copy authoritative and 'receive' still never pushes. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../public/locales/ar/translation.json | 5 +- .../public/locales/bn/translation.json | 5 +- .../public/locales/bo/translation.json | 5 +- .../public/locales/de/translation.json | 5 +- .../public/locales/el/translation.json | 5 +- .../public/locales/es/translation.json | 5 +- .../public/locales/fa/translation.json | 5 +- .../public/locales/fr/translation.json | 5 +- .../public/locales/he/translation.json | 5 +- .../public/locales/hi/translation.json | 5 +- .../public/locales/hu/translation.json | 5 +- .../public/locales/id/translation.json | 5 +- .../public/locales/it/translation.json | 5 +- .../public/locales/ja/translation.json | 5 +- .../public/locales/ko/translation.json | 5 +- .../public/locales/ms/translation.json | 5 +- .../public/locales/nl/translation.json | 5 +- .../public/locales/pl/translation.json | 5 +- .../public/locales/pt-BR/translation.json | 5 +- .../public/locales/pt/translation.json | 5 +- .../public/locales/ro/translation.json | 5 +- .../public/locales/ru/translation.json | 5 +- .../public/locales/si/translation.json | 5 +- .../public/locales/sl/translation.json | 5 +- .../public/locales/sv/translation.json | 5 +- .../public/locales/ta/translation.json | 5 +- .../public/locales/th/translation.json | 5 +- .../public/locales/tr/translation.json | 5 +- .../public/locales/uk/translation.json | 5 +- .../public/locales/uz/translation.json | 5 +- .../public/locales/vi/translation.json | 5 +- .../public/locales/zh-CN/translation.json | 5 +- .../public/locales/zh-TW/translation.json | 5 +- .../services/webdav-metadata-sync.test.ts | 277 ++++++++++++++++++ .../settings/integrations/WebDAVForm.tsx | 17 ++ .../src/services/webdav/WebDAVSync.ts | 101 ++++++- packages/foliate-js | 2 +- 37 files changed, 527 insertions(+), 35 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/webdav-metadata-sync.test.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index dc5f9303..65cf1ddb 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1923,5 +1923,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "يؤدي هذا إلى مسح تقدم القراءة والملاحظات والإشارات المرجعية نهائيًا. لا يمكن التراجع عن هذا الإجراء.", "Also erase reading progress, notes, and bookmarks.": "يمسح أيضًا تقدم القراءة والملاحظات والإشارات المرجعية.", "Background Image (Library)": "صورة الخلفية (المكتبة)", - "Background Image (Reader)": "صورة الخلفية (واجهة القراءة)" + "Background Image (Reader)": "صورة الخلفية (واجهة القراءة)", + "updated metadata for {{n}} book(s)": "تم تحديث البيانات الوصفية لـ {{n}} كتاب", + "{{n}} metadata": "{{n}} بيانات وصفية", + "Metadata updated": "تم تحديث البيانات الوصفية" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 68fe177d..946cfef5 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "এটি স্থায়ীভাবে পড়ার অগ্রগতি, নোট এবং বুকমার্ক মুছে ফেলে। এটি পূর্বাবস্থায় ফেরানো যাবে না।", "Also erase reading progress, notes, and bookmarks.": "পড়ার অগ্রগতি, নোট এবং বুকমার্কও মুছে ফেলে।", "Background Image (Library)": "ব্যাকগ্রাউন্ড ছবি (লাইব্রেরি)", - "Background Image (Reader)": "ব্যাকগ্রাউন্ড ছবি (রিডার)" + "Background Image (Reader)": "ব্যাকগ্রাউন্ড ছবি (রিডার)", + "updated metadata for {{n}} book(s)": "{{n}}টি বইয়ের মেটাডেটা আপডেট হয়েছে", + "{{n}} metadata": "{{n}} মেটাডেটা", + "Metadata updated": "মেটাডেটা আপডেট হয়েছে" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 934aea2f..3e6fa4c9 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1758,5 +1758,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "འདིས་ཀློག་པའི་ཡར་འཕེལ་དང་། ཟིན་བྲིས། དཔེ་རྟགས་བཅས་གཏན་དུ་སུབ་ངེས་ཡིན། ཕྱིར་ལྡོག་བྱེད་མི་ཐུབ།", "Also erase reading progress, notes, and bookmarks.": "ཀློག་པའི་ཡར་འཕེལ་དང་། ཟིན་བྲིས། དཔེ་རྟགས་བཅས་ཀྱང་སུབ་པ།", "Background Image (Library)": "གནས་སྟངས་ཀྱི་རི་མོ། (དེབ་མཛོད།)", - "Background Image (Reader)": "གནས་སྟངས་ཀྱི་རི་མོ། (ཀློག་ངོས།)" + "Background Image (Reader)": "གནས་སྟངས་ཀྱི་རི་མོ། (ཀློག་ངོས།)", + "updated metadata for {{n}} book(s)": "དཔེ་ཆ་{{n}}་ཡི་གནད་སྨིན་གོ་དོན་གསར་བསྒྱུར་བྱས།", + "{{n}} metadata": "{{n}} གནད་སྨིན་གོ་དོན།", + "Metadata updated": "གནད་སྨིན་གོ་དོན་གསར་བསྒྱུར་བྱས།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 4acadf9e..055c4520 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Dadurch werden Lesefortschritt, Notizen und Lesezeichen dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", "Also erase reading progress, notes, and bookmarks.": "Löscht auch Lesefortschritt, Notizen und Lesezeichen.", "Background Image (Library)": "Hintergrundbild (Bibliothek)", - "Background Image (Reader)": "Hintergrundbild (Leseansicht)" + "Background Image (Reader)": "Hintergrundbild (Leseansicht)", + "updated metadata for {{n}} book(s)": "Metadaten für {{n}} Buch/Bücher aktualisiert", + "{{n}} metadata": "{{n}} Metadaten", + "Metadata updated": "Metadaten aktualisiert" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index c066b538..0c5f936c 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Αυτό διαγράφει οριστικά την πρόοδο ανάγνωσης, τις σημειώσεις και τους σελιδοδείκτες. Δεν είναι δυνατή η αναίρεση.", "Also erase reading progress, notes, and bookmarks.": "Διαγράφει επίσης την πρόοδο ανάγνωσης, τις σημειώσεις και τους σελιδοδείκτες.", "Background Image (Library)": "Εικόνα φόντου (Βιβλιοθήκη)", - "Background Image (Reader)": "Εικόνα φόντου (Προβολή ανάγνωσης)" + "Background Image (Reader)": "Εικόνα φόντου (Προβολή ανάγνωσης)", + "updated metadata for {{n}} book(s)": "ενημερώθηκαν τα μεταδεδομένα για {{n}} βιβλίο/α", + "{{n}} metadata": "{{n}} μεταδεδομένα", + "Metadata updated": "Ενημερωμένα μεταδεδομένα" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 33a67d6d..d4097ee2 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1824,5 +1824,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Esto borra permanentemente el progreso de lectura, las notas y los marcadores. Esta acción no se puede deshacer.", "Also erase reading progress, notes, and bookmarks.": "También borra el progreso de lectura, las notas y los marcadores.", "Background Image (Library)": "Imagen de fondo (Biblioteca)", - "Background Image (Reader)": "Imagen de fondo (Lector)" + "Background Image (Reader)": "Imagen de fondo (Lector)", + "updated metadata for {{n}} book(s)": "metadatos actualizados de {{n}} libro(s)", + "{{n}} metadata": "{{n}} metadatos", + "Metadata updated": "Metadatos actualizados" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 3bc18b5d..825f3134 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "این کار وضعیت مطالعه، یادداشت‌ها و نشانک‌ها را برای همیشه پاک می‌کند. این عمل قابل بازگشت نیست.", "Also erase reading progress, notes, and bookmarks.": "همچنین وضعیت مطالعه، یادداشت‌ها و نشانک‌ها را پاک می‌کند.", "Background Image (Library)": "تصویر پس‌زمینه (کتابخانه)", - "Background Image (Reader)": "تصویر پس‌زمینه (صفحه مطالعه)" + "Background Image (Reader)": "تصویر پس‌زمینه (صفحه مطالعه)", + "updated metadata for {{n}} book(s)": "متادیتای {{n}} کتاب به‌روزرسانی شد", + "{{n}} metadata": "{{n}} متادیتا", + "Metadata updated": "متادیتا به‌روزرسانی شد" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 29027650..22d4bbe8 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1824,5 +1824,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Cela efface définitivement la progression de lecture, les notes et les signets. Cette action est irréversible.", "Also erase reading progress, notes, and bookmarks.": "Efface aussi la progression de lecture, les notes et les signets.", "Background Image (Library)": "Image de Fond (Bibliothèque)", - "Background Image (Reader)": "Image de Fond (Vue de lecture)" + "Background Image (Reader)": "Image de Fond (Vue de lecture)", + "updated metadata for {{n}} book(s)": "métadonnées mises à jour pour {{n}} livre(s)", + "{{n}} metadata": "{{n}} métadonnées", + "Metadata updated": "Métadonnées mises à jour" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 968507e9..dffeb5ae 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1824,5 +1824,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "פעולה זו מוחקת לצמיתות את התקדמות הקריאה, ההערות והסימניות. לא ניתן לבטל פעולה זו.", "Also erase reading progress, notes, and bookmarks.": "מוחק גם את התקדמות הקריאה, ההערות והסימניות.", "Background Image (Library)": "תמונת רקע (ספרייה)", - "Background Image (Reader)": "תמונת רקע (מצב קריאה)" + "Background Image (Reader)": "תמונת רקע (מצב קריאה)", + "updated metadata for {{n}} book(s)": "המטא-נתונים עודכנו עבור {{n}} ספרים", + "{{n}} metadata": "{{n}} מטא-נתונים", + "Metadata updated": "המטא-נתונים עודכנו" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 31531b9d..49f37909 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "यह पढ़ने की प्रगति, नोट्स और बुकमार्क को स्थायी रूप से मिटा देता है। इसे पूर्ववत नहीं किया जा सकता।", "Also erase reading progress, notes, and bookmarks.": "पढ़ने की प्रगति, नोट्स और बुकमार्क भी मिटा देता है।", "Background Image (Library)": "पृष्ठभूमि छवि (लाइब्रेरी)", - "Background Image (Reader)": "पृष्ठभूमि छवि (रीडर)" + "Background Image (Reader)": "पृष्ठभूमि छवि (रीडर)", + "updated metadata for {{n}} book(s)": "{{n}} पुस्तक(ों) के मेटाडेटा अपडेट किए गए", + "{{n}} metadata": "{{n}} मेटाडेटा", + "Metadata updated": "मेटाडेटा अपडेट किया गया" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 2922bbc0..cd934cd3 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Ez véglegesen törli az olvasási haladást, a jegyzeteket és a könyvjelzőket. A művelet nem vonható vissza.", "Also erase reading progress, notes, and bookmarks.": "Az olvasási haladást, jegyzeteket és könyvjelzőket is törli.", "Background Image (Library)": "Háttérkép (Könyvtár)", - "Background Image (Reader)": "Háttérkép (Olvasónézet)" + "Background Image (Reader)": "Háttérkép (Olvasónézet)", + "updated metadata for {{n}} book(s)": "{{n}} könyv metaadatai frissítve", + "{{n}} metadata": "{{n}} metaadat", + "Metadata updated": "Metaadatok frissítve" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 50168e1e..d5aa72c7 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1758,5 +1758,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Ini menghapus permanen progres membaca, catatan, dan penanda. Tindakan ini tidak dapat dibatalkan.", "Also erase reading progress, notes, and bookmarks.": "Juga menghapus progres membaca, catatan, dan penanda.", "Background Image (Library)": "Gambar Latar Belakang (Perpustakaan)", - "Background Image (Reader)": "Gambar Latar Belakang (Tampilan Baca)" + "Background Image (Reader)": "Gambar Latar Belakang (Tampilan Baca)", + "updated metadata for {{n}} book(s)": "metadata diperbarui untuk {{n}} buku", + "{{n}} metadata": "{{n}} metadata", + "Metadata updated": "Metadata diperbarui" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index d835f298..b676b933 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1824,5 +1824,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Questa operazione cancella definitivamente il progresso di lettura, le note e i segnalibri. Non può essere annullata.", "Also erase reading progress, notes, and bookmarks.": "Cancella anche il progresso di lettura, le note e i segnalibri.", "Background Image (Library)": "Immagine di sfondo (Libreria)", - "Background Image (Reader)": "Immagine di sfondo (Vista di lettura)" + "Background Image (Reader)": "Immagine di sfondo (Vista di lettura)", + "updated metadata for {{n}} book(s)": "metadati aggiornati per {{n}} libro/i", + "{{n}} metadata": "{{n}} metadati", + "Metadata updated": "Metadati aggiornati" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index f591c979..96da46d9 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1758,5 +1758,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "読書進捗、メモ、ブックマークが完全に消去されます。この操作は元に戻せません。", "Also erase reading progress, notes, and bookmarks.": "読書進捗、メモ、ブックマークも消去します。", "Background Image (Library)": "背景画像(ライブラリ)", - "Background Image (Reader)": "背景画像(リーダー)" + "Background Image (Reader)": "背景画像(リーダー)", + "updated metadata for {{n}} book(s)": "{{n}} 冊分のメタデータを更新", + "{{n}} metadata": "{{n}} 件メタデータ", + "Metadata updated": "更新したメタデータ" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 125664b2..cedba22f 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1758,5 +1758,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "읽기 진행 상황, 메모, 북마크가 영구적으로 지워집니다. 이 작업은 되돌릴 수 없습니다.", "Also erase reading progress, notes, and bookmarks.": "읽기 진행 상황, 메모, 북마크도 함께 지웁니다.", "Background Image (Library)": "배경 이미지 (라이브러리)", - "Background Image (Reader)": "배경 이미지 (리더)" + "Background Image (Reader)": "배경 이미지 (리더)", + "updated metadata for {{n}} book(s)": "{{n}}권의 메타데이터 업데이트됨", + "{{n}} metadata": "{{n}} 메타데이터", + "Metadata updated": "메타데이터 업데이트됨" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 11c51d5f..366b6ac4 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1758,5 +1758,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Ini memadam kemajuan membaca, nota dan penanda buku secara kekal. Tindakan ini tidak boleh dibatalkan.", "Also erase reading progress, notes, and bookmarks.": "Turut memadam kemajuan membaca, nota dan penanda buku.", "Background Image (Library)": "Imej Latar Belakang (Perpustakaan)", - "Background Image (Reader)": "Imej Latar Belakang (Paparan Bacaan)" + "Background Image (Reader)": "Imej Latar Belakang (Paparan Bacaan)", + "updated metadata for {{n}} book(s)": "metadata dikemas kini untuk {{n}} buku", + "{{n}} metadata": "{{n}} metadata", + "Metadata updated": "Metadata dikemas kini" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 3adab7a6..bdea4d29 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Hiermee worden leesvoortgang, notities en bladwijzers permanent gewist. Dit kan niet ongedaan worden gemaakt.", "Also erase reading progress, notes, and bookmarks.": "Wist ook leesvoortgang, notities en bladwijzers.", "Background Image (Library)": "Achtergrondafbeelding (Bibliotheek)", - "Background Image (Reader)": "Achtergrondafbeelding (Leesweergave)" + "Background Image (Reader)": "Achtergrondafbeelding (Leesweergave)", + "updated metadata for {{n}} book(s)": "metadata bijgewerkt voor {{n}} boek(en)", + "{{n}} metadata": "{{n}} metadata", + "Metadata updated": "Metadata bijgewerkt" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 21777d17..098c4b52 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1857,5 +1857,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Spowoduje to trwałe usunięcie postępu czytania, notatek i zakładek. Tej operacji nie można cofnąć.", "Also erase reading progress, notes, and bookmarks.": "Usuwa również postęp czytania, notatki i zakładki.", "Background Image (Library)": "Obraz tła (Biblioteka)", - "Background Image (Reader)": "Obraz tła (Widok czytania)" + "Background Image (Reader)": "Obraz tła (Widok czytania)", + "updated metadata for {{n}} book(s)": "zaktualizowano metadane dla {{n}} książek", + "{{n}} metadata": "{{n}} metadane", + "Metadata updated": "Zaktualizowane metadane" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index 353dd54e..04c1465a 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1824,5 +1824,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Isso apaga permanentemente o progresso de leitura, as notas e os marcadores. Esta ação não pode ser desfeita.", "Also erase reading progress, notes, and bookmarks.": "Também apaga o progresso de leitura, as notas e os marcadores.", "Background Image (Library)": "Imagem de fundo (Biblioteca)", - "Background Image (Reader)": "Imagem de fundo (Vista de leitura)" + "Background Image (Reader)": "Imagem de fundo (Vista de leitura)", + "updated metadata for {{n}} book(s)": "metadados atualizados de {{n}} livro(s)", + "{{n}} metadata": "{{n}} metadados", + "Metadata updated": "Metadados atualizados" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 94bc809b..a1f420b2 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1824,5 +1824,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Isto apaga permanentemente o progresso de leitura, notas e marcadores. Esta ação não pode ser desfeita.", "Also erase reading progress, notes, and bookmarks.": "Também apaga o progresso de leitura, notas e marcadores.", "Background Image (Library)": "Imagem de Fundo (Biblioteca)", - "Background Image (Reader)": "Imagem de Fundo (Vista de leitura)" + "Background Image (Reader)": "Imagem de Fundo (Vista de leitura)", + "updated metadata for {{n}} book(s)": "metadados atualizados de {{n}} livro(s)", + "{{n}} metadata": "{{n}} metadados", + "Metadata updated": "Metadados atualizados" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index dcd05cae..05c6222a 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1824,5 +1824,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Aceasta șterge definitiv progresul citirii, notele și marcajele. Această acțiune nu poate fi anulată.", "Also erase reading progress, notes, and bookmarks.": "Șterge de asemenea progresul citirii, notele și marcajele.", "Background Image (Library)": "Imagine de fundal (Bibliotecă)", - "Background Image (Reader)": "Imagine de fundal (Vizualizare de lectură)" + "Background Image (Reader)": "Imagine de fundal (Vizualizare de lectură)", + "updated metadata for {{n}} book(s)": "metadate actualizate pentru {{n}} carte/cărți", + "{{n}} metadata": "{{n}} metadate", + "Metadata updated": "Metadate actualizate" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index d11145b4..611041a1 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1857,5 +1857,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Это безвозвратно удалит прогресс чтения, заметки и закладки. Это действие нельзя отменить.", "Also erase reading progress, notes, and bookmarks.": "Также удаляет прогресс чтения, заметки и закладки.", "Background Image (Library)": "Фоновое изображение (Библиотека)", - "Background Image (Reader)": "Фоновое изображение (Режим чтения)" + "Background Image (Reader)": "Фоновое изображение (Режим чтения)", + "updated metadata for {{n}} book(s)": "обновлены метаданные для {{n}} книг", + "{{n}} metadata": "{{n}} метаданные", + "Metadata updated": "Метаданные обновлены" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 46b4d0b5..7e3a7bb3 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "මෙය කියවීමේ ප්‍රගතිය, සටහන් සහ පොත් සලකුණු ස්ථිරවම මකා දමයි. මෙය අහෝසි කළ නොහැක.", "Also erase reading progress, notes, and bookmarks.": "කියවීමේ ප්‍රගතිය, සටහන් සහ පොත් සලකුණු ද මකා දමයි.", "Background Image (Library)": "පසුබිම් රූපය (පුස්තකාලය)", - "Background Image (Reader)": "පසුබිම් රූපය (කියවීමේ දසුන)" + "Background Image (Reader)": "පසුබිම් රූපය (කියවීමේ දසුන)", + "updated metadata for {{n}} book(s)": "පොත් {{n}}ක මෙටාඩේටා යාවත්කාලීන කරන ලදී", + "{{n}} metadata": "{{n}} මෙටාඩේටා", + "Metadata updated": "මෙටාඩේටා යාවත්කාලීන කරන ලදී" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index df274a9c..fb449824 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1857,5 +1857,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "To trajno izbriše napredek branja, opombe in zaznamke. Tega ni mogoče razveljaviti.", "Also erase reading progress, notes, and bookmarks.": "Izbriše tudi napredek branja, opombe in zaznamke.", "Background Image (Library)": "Slikovno ozadje (Knjižnica)", - "Background Image (Reader)": "Slikovno ozadje (Bralni pogled)" + "Background Image (Reader)": "Slikovno ozadje (Bralni pogled)", + "updated metadata for {{n}} book(s)": "posodobljeni metapodatki za {{n}} knjig", + "{{n}} metadata": "{{n}} metapodatki", + "Metadata updated": "Metapodatki posodobljeni" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index a62607c6..3c5ea89b 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Detta raderar permanent läsframsteg, anteckningar och bokmärken. Detta kan inte ångras.", "Also erase reading progress, notes, and bookmarks.": "Raderar även läsframsteg, anteckningar och bokmärken.", "Background Image (Library)": "Bakgrundsbild (Bibliotek)", - "Background Image (Reader)": "Bakgrundsbild (Läsvy)" + "Background Image (Reader)": "Bakgrundsbild (Läsvy)", + "updated metadata for {{n}} book(s)": "uppdaterade metadata för {{n}} bok/böcker", + "{{n}} metadata": "{{n}} metadata", + "Metadata updated": "Metadata uppdaterad" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 7a053aa7..b761dc0b 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "இது வாசிப்பு முன்னேற்றம், குறிப்புகள் மற்றும் புக்மார்க்குகளை நிரந்தரமாக அழிக்கும். இதை மீட்டெடுக்க முடியாது.", "Also erase reading progress, notes, and bookmarks.": "வாசிப்பு முன்னேற்றம், குறிப்புகள் மற்றும் புக்மார்க்குகளையும் அழிக்கும்.", "Background Image (Library)": "பின்னணி படம் (நூலகம்)", - "Background Image (Reader)": "பின்னணி படம் (வாசிப்புப் பக்கம்)" + "Background Image (Reader)": "பின்னணி படம் (வாசிப்புப் பக்கம்)", + "updated metadata for {{n}} book(s)": "{{n}} புத்தகத்தின் மெட்டாடேட்டா புதுப்பிக்கப்பட்டது", + "{{n}} metadata": "{{n}} மெட்டாடேட்டா", + "Metadata updated": "மெட்டாடேட்டா புதுப்பிக்கப்பட்டது" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 44edd147..aa50894b 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1758,5 +1758,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "การดำเนินการนี้จะลบความคืบหน้าในการอ่าน บันทึก และบุ๊กมาร์กอย่างถาวร ไม่สามารถเลิกทำได้", "Also erase reading progress, notes, and bookmarks.": "ลบความคืบหน้าในการอ่าน บันทึก และบุ๊กมาร์กด้วย", "Background Image (Library)": "ภาพพื้นหลัง (คลังหนังสือ)", - "Background Image (Reader)": "ภาพพื้นหลัง (มุมมองการอ่าน)" + "Background Image (Reader)": "ภาพพื้นหลัง (มุมมองการอ่าน)", + "updated metadata for {{n}} book(s)": "อัปเดตเมทาดาตาสำหรับ {{n}} เล่ม", + "{{n}} metadata": "{{n}} เมทาดาตา", + "Metadata updated": "อัปเดตเมทาดาตาแล้ว" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 5ecfbb9b..de8b2566 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Bu, okuma ilerlemesini, notları ve yer işaretlerini kalıcı olarak siler. Bu işlem geri alınamaz.", "Also erase reading progress, notes, and bookmarks.": "Okuma ilerlemesi, notlar ve yer işaretlerini de siler.", "Background Image (Library)": "Arka Plan Görüntüsü (Kütüphane)", - "Background Image (Reader)": "Arka Plan Görüntüsü (Okuma Görünümü)" + "Background Image (Reader)": "Arka Plan Görüntüsü (Okuma Görünümü)", + "updated metadata for {{n}} book(s)": "{{n}} kitabın meta verileri güncellendi", + "{{n}} metadata": "{{n}} meta veri", + "Metadata updated": "Meta veriler güncellendi" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 71f98620..606ed5e5 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1857,5 +1857,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Це назавжди видалить проґрес читання, нотатки та закладки. Цю дію не можна скасувати.", "Also erase reading progress, notes, and bookmarks.": "Також видаляє проґрес читання, нотатки та закладки.", "Background Image (Library)": "Фонове зображення (Бібліотека)", - "Background Image (Reader)": "Фонове зображення (Режим читання)" + "Background Image (Reader)": "Фонове зображення (Режим читання)", + "updated metadata for {{n}} book(s)": "оновлено метадані для {{n}} книг", + "{{n}} metadata": "{{n}} метадані", + "Metadata updated": "Метадані оновлено" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 4054d10f..db9cde80 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1791,5 +1791,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Bu oʻqish jarayoni, eslatmalar va xatchoʻplarni butunlay oʻchiradi. Buni qaytarib boʻlmaydi.", "Also erase reading progress, notes, and bookmarks.": "Shuningdek, oʻqish jarayoni, eslatmalar va xatchoʻplarni ham oʻchiradi.", "Background Image (Library)": "Fon rasmi (Kutubxona)", - "Background Image (Reader)": "Fon rasmi (Oʻqish koʻrinishi)" + "Background Image (Reader)": "Fon rasmi (Oʻqish koʻrinishi)", + "updated metadata for {{n}} book(s)": "{{n}} ta kitob metamaʼlumotlari yangilandi", + "{{n}} metadata": "{{n}} metamaʼlumot", + "Metadata updated": "Metamaʼlumotlar yangilandi" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index bcbc17a2..81b6f9eb 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1758,5 +1758,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "Thao tác này xóa vĩnh viễn tiến độ đọc, ghi chú và dấu trang. Không thể hoàn tác.", "Also erase reading progress, notes, and bookmarks.": "Đồng thời xóa tiến độ đọc, ghi chú và dấu trang.", "Background Image (Library)": "Ảnh nền (Thư viện)", - "Background Image (Reader)": "Ảnh nền (Trình đọc)" + "Background Image (Reader)": "Ảnh nền (Trình đọc)", + "updated metadata for {{n}} book(s)": "đã cập nhật siêu dữ liệu cho {{n}} sách", + "{{n}} metadata": "{{n}} siêu dữ liệu", + "Metadata updated": "Đã cập nhật siêu dữ liệu" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 69baf40b..dc5f87cd 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1758,5 +1758,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "这将永久清除阅读进度、笔记和书签,且无法撤销。", "Also erase reading progress, notes, and bookmarks.": "同时清除阅读进度、笔记和书签。", "Background Image (Library)": "背景图片(书库)", - "Background Image (Reader)": "背景图片(阅读界面)" + "Background Image (Reader)": "背景图片(阅读界面)", + "updated metadata for {{n}} book(s)": "已更新 {{n}} 本书的元数据", + "{{n}} metadata": "{{n}} 元数据", + "Metadata updated": "已更新元数据" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index ea425542..cf287825 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1758,5 +1758,8 @@ "This permanently erases reading progress, notes, and bookmarks. This cannot be undone.": "這將永久清除閱讀進度、筆記和書籤,且無法復原。", "Also erase reading progress, notes, and bookmarks.": "同時清除閱讀進度、筆記和書籤。", "Background Image (Library)": "背景圖片(書庫)", - "Background Image (Reader)": "背景圖片(閱讀介面)" + "Background Image (Reader)": "背景圖片(閱讀介面)", + "updated metadata for {{n}} book(s)": "已更新 {{n}} 本書的中繼資料", + "{{n}} metadata": "{{n}} 中繼資料", + "Metadata updated": "已更新中繼資料" } diff --git a/apps/readest-app/src/__tests__/services/webdav-metadata-sync.test.ts b/apps/readest-app/src/__tests__/services/webdav-metadata-sync.test.ts new file mode 100644 index 00000000..a6f9b51e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/webdav-metadata-sync.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { WebDAVSettings } from '@/types/settings'; +import type { Book, BookConfig, BookNote } from '@/types/book'; + +/** + * Regression tests for issue #4756: once a device already has a book in its + * local library, the manual "Sync now" path never pulled the book's metadata + * (title / author / cover) back from the shared `library.json`, and the final + * index re-push clobbered the remote with the device's stale copy. + * + * These tests mock the WebDAVClient I/O primitives so we can drive + * `syncLibrary` deterministically without a real server, and assert the + * last-writer-wins reconciliation on `book.updatedAt`. + */ + +vi.mock('@/services/webdav/WebDAVClient', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getFile: vi.fn(), + getFileBinary: vi.fn(), + headFile: vi.fn(), + listDirectory: vi.fn(), + putFile: vi.fn(), + putFileBinary: vi.fn(), + ensureDirectory: vi.fn(), + deleteDirectory: vi.fn(), + }; +}); + +import { + getFile, + getFileBinary, + headFile, + listDirectory, + putFile, + putFileBinary, + ensureDirectory, +} from '@/services/webdav/WebDAVClient'; +import { + syncLibrary, + type RemoteLibraryIndex, + type RemoteBookConfig, +} from '@/services/webdav/WebDAVSync'; + +const settings: WebDAVSettings = { + enabled: true, + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'secret', + rootPath: '/', +}; + +const makeLocalBook = (overrides: Partial = {}): Book => ({ + hash: 'h1', + format: 'EPUB', + title: 'Old Title', + sourceTitle: 'Old Title', + author: 'Old Author', + createdAt: 1, + updatedAt: 100, + ...overrides, +}); + +const makeRemoteIndex = (book: Book, updatedAt = book.updatedAt): RemoteLibraryIndex => ({ + schemaVersion: 1, + updatedAt, + books: [book], +}); + +/** + * Route `getFile` by path: library.json → index JSON, config.json → the + * supplied remote envelope (or null when none). + */ +const wireGetFile = ( + index: RemoteLibraryIndex | null, + remoteConfig: RemoteBookConfig | null = null, +) => { + (getFile as ReturnType).mockImplementation(async (_client, path: string) => { + if (path.endsWith('library.json')) return index ? JSON.stringify(index) : null; + if (path.endsWith('config.json')) return remoteConfig ? JSON.stringify(remoteConfig) : null; + return null; + }); +}; + +/** Capture the library index that was re-pushed at the end of the sync. */ +const capturePushedIndex = (): { value: RemoteLibraryIndex | null } => { + const captured: { value: RemoteLibraryIndex | null } = { value: null }; + (putFile as ReturnType).mockImplementation(async (_client, path: string, body) => { + if (path.endsWith('library.json')) captured.value = JSON.parse(body as string); + }); + return captured; +}; + +/** Capture the per-book config envelope pushed to /config.json. */ +const capturePushedConfig = (): { value: RemoteBookConfig | null } => { + const captured: { value: RemoteBookConfig | null } = { value: null }; + (putFile as ReturnType).mockImplementation(async (_client, path: string, body) => { + if (path.endsWith('config.json')) captured.value = JSON.parse(body as string); + }); + return captured; +}; + +const makeNote = (id: string, updatedAt: number): BookNote => ({ + id, + type: 'annotation', + cfi: `cfi-${id}`, + note: '', + createdAt: updatedAt, + updatedAt, +}); + +const makeRemoteConfig = (overrides: Partial = {}): RemoteBookConfig => ({ + schemaVersion: 1, + bookHash: 'h1', + config: { updatedAt: 100 }, + booknotes: [], + writerDeviceId: 'mobile', + writerVersion: 'readest-webdav-1', + updatedAt: 100, + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); + (listDirectory as ReturnType).mockResolvedValue([]); + (getFileBinary as ReturnType).mockResolvedValue(new ArrayBuffer(8)); + (headFile as ReturnType).mockResolvedValue(null); + (putFile as ReturnType).mockResolvedValue(undefined); + (putFileBinary as ReturnType).mockResolvedValue(undefined); + (ensureDirectory as ReturnType).mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const baseOptions = () => ({ + strategy: 'silent' as const, + syncBooks: false, + deviceId: 'pc-device', + loadConfig: async (): Promise => ({ updatedAt: 50, booknotes: [] }), + loadBookFile: async () => null, + loadBookCover: async () => null, + onProgress: () => {}, +}); + +describe('syncLibrary metadata reconciliation (#4756)', () => { + test('pulls newer remote metadata for a book the device already has', async () => { + const local = makeLocalBook({ updatedAt: 100 }); + const remote = makeLocalBook({ + title: 'New Title', + author: 'New Author', + updatedAt: 200, + }); + wireGetFile(makeRemoteIndex(remote, 200)); + const pushedIndex = capturePushedIndex(); + + const updateBookMetadata = vi.fn(async (_book: Book) => {}); + const saveBookCover = vi.fn(async (_book: Book, _bytes: ArrayBuffer) => {}); + + const result = await syncLibrary(settings, [local], { + ...baseOptions(), + updateBookMetadata, + saveBookCover, + }); + + // The device must learn the remote title/author. + expect(updateBookMetadata).toHaveBeenCalledTimes(1); + const merged = updateBookMetadata.mock.calls[0]![0]; + expect(merged.title).toBe('New Title'); + expect(merged.author).toBe('New Author'); + expect(result.metadataUpdated).toBe(1); + + // The cover must be re-pulled so a changed cover propagates. + expect(saveBookCover).toHaveBeenCalledTimes(1); + + // The re-pushed index must carry the newer metadata, not clobber it. + expect(pushedIndex.value).not.toBeNull(); + const indexedBook = pushedIndex.value!.books.find((b) => b.hash === 'h1')!; + expect(indexedBook.title).toBe('New Title'); + }); + + test('does not overwrite local metadata when the local copy is newer', async () => { + const local = makeLocalBook({ title: 'Local Newer', updatedAt: 300 }); + const remote = makeLocalBook({ title: 'Remote Older', updatedAt: 200 }); + wireGetFile(makeRemoteIndex(remote, 200)); + const pushedIndex = capturePushedIndex(); + + const updateBookMetadata = vi.fn(async (_book: Book) => {}); + + const result = await syncLibrary(settings, [local], { + ...baseOptions(), + updateBookMetadata, + }); + + expect(updateBookMetadata).not.toHaveBeenCalled(); + expect(result.metadataUpdated).toBe(0); + + // Local wins: the re-pushed index keeps the local title. + const indexedBook = pushedIndex.value!.books.find((b) => b.hash === 'h1')!; + expect(indexedBook.title).toBe('Local Newer'); + }); +}); + +describe('syncLibrary config merge before push (Sync now must not blind-overwrite)', () => { + test('unions remote booknotes into the pushed config instead of clobbering them', async () => { + const local = makeLocalBook({ updatedAt: 100 }); + // Same updatedAt in the index so the metadata pass is a no-op — this + // isolates the config/notes path. + wireGetFile( + makeRemoteIndex(makeLocalBook({ updatedAt: 100 }), 100), + makeRemoteConfig({ config: { updatedAt: 100 }, booknotes: [makeNote('remote-note', 100)] }), + ); + const pushedConfig = capturePushedConfig(); + + await syncLibrary(settings, [local], { + ...baseOptions(), + loadConfig: async (): Promise => ({ + updatedAt: 50, + booknotes: [makeNote('local-note', 50)], + }), + }); + + // The pushed envelope must carry BOTH notes — a blind push would have + // dropped the peer's note that this device never pulled. + expect(pushedConfig.value).not.toBeNull(); + const ids = pushedConfig.value!.booknotes.map((n) => n.id).sort(); + expect(ids).toEqual(['local-note', 'remote-note']); + }); + + test('does not regress newer remote progress with an older local push', async () => { + const local = makeLocalBook({ updatedAt: 100 }); + wireGetFile( + makeRemoteIndex(makeLocalBook({ updatedAt: 100 }), 100), + makeRemoteConfig({ config: { updatedAt: 200, progress: [50, 100] }, booknotes: [] }), + ); + const pushedConfig = capturePushedConfig(); + + await syncLibrary(settings, [local], { + ...baseOptions(), + loadConfig: async (): Promise => ({ + updatedAt: 50, + progress: [10, 100], + booknotes: [], + }), + }); + + // Remote progress is newer (updatedAt 200 > 50): the LWW merge must push + // the remote's page, never regress it back to the local page. + expect(pushedConfig.value!.config.progress).toEqual([50, 100]); + }); + + test('send strategy keeps the blind push (local authoritative, no pull-merge)', async () => { + const local = makeLocalBook({ updatedAt: 100 }); + wireGetFile( + null, + makeRemoteConfig({ config: { updatedAt: 200 }, booknotes: [makeNote('remote-note', 200)] }), + ); + const pushedConfig = capturePushedConfig(); + + await syncLibrary(settings, [local], { + ...baseOptions(), + strategy: 'send', + loadConfig: async (): Promise => ({ + updatedAt: 50, + booknotes: [makeNote('local-note', 50)], + }), + }); + + // 'send' deliberately treats local as the source of truth — it must not + // pull the remote note in. + const ids = pushedConfig.value!.booknotes.map((n) => n.id); + expect(ids).toEqual(['local-note']); + }); +}); diff --git a/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx b/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx index cb6545d9..70d61f25 100644 --- a/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx +++ b/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx @@ -410,6 +410,20 @@ const WebDAVForm: React.FC = ({ onBack }) => { // library that's already persisted on disk. setLibrary(newLibrary); }, + updateBookMetadata: async (book) => { + if (!appService) return; + // The cover bytes were just refreshed via saveBookCover, so + // regenerate the device-local blob URL the bookshelf renders. + try { + book.coverImageUrl = await appService.generateCoverImageUrl(book); + } catch (e) { + console.warn('WD library sync: cover URL generation failed', book.hash, e); + } + book.syncedAt = Date.now(); + // updateBook persists via saveLibraryBooks and refreshes the store, + // so the new title / author / cover show up without a reload. + await useLibraryStore.getState().updateBook(envConfig, book); + }, onProgress: ({ book, index, total, action }) => { const actionStr = action === 'downloading' ? _('Downloading') : _('Uploading'); updateProgress( @@ -431,6 +445,9 @@ const WebDAVForm: React.FC = ({ onBack }) => { if (result.configsDownloaded > 0) { parts.push(_('pulled progress for {{n}} book(s)', { n: result.configsDownloaded })); } + if (result.metadataUpdated > 0) { + parts.push(_('updated metadata for {{n}} book(s)', { n: result.metadataUpdated })); + } if (result.configsUploaded > 0) { parts.push(_('pushed {{n}} config(s)', { n: result.configsUploaded })); } diff --git a/apps/readest-app/src/services/webdav/WebDAVSync.ts b/apps/readest-app/src/services/webdav/WebDAVSync.ts index 1c753ec7..d64c9f4d 100644 --- a/apps/readest-app/src/services/webdav/WebDAVSync.ts +++ b/apps/readest-app/src/services/webdav/WebDAVSync.ts @@ -140,6 +140,28 @@ const mergeNotes = (local: BookNote[], remote: BookNote[]): BookNote[] => { return Array.from(byId.values()); }; +/** + * Overlay the user-facing metadata of `remote` onto `local`, preserving every + * device-local / file-system field: `filePath`, `sourceTitle` (which names the + * on-disk file), `coverImageUrl` (a device-local blob URL the caller + * regenerates), reading progress, reading status, group membership, `hash`, + * `format`, `createdAt`, etc. + * + * Only the fields a metadata edit actually changes travel — this list mirrors + * `getBookWithUpdatedMetadata` in `utils/book.ts`, which is the local side of + * the same operation. The cover image is replicated separately as cover.png + * bytes (see the reconciliation pass in `syncLibrary`), so it is intentionally + * absent here. + */ +const mergeRemoteBookMetadata = (local: Book, remote: Book): Book => ({ + ...local, + title: remote.title, + author: remote.author, + metadata: remote.metadata ?? local.metadata, + primaryLanguage: remote.primaryLanguage ?? local.primaryLanguage, + updatedAt: remote.updatedAt, +}); + export interface PullResult { /** True when the remote had a config and we merged something into local. */ applied: boolean; @@ -572,6 +594,11 @@ export interface SyncLibraryResult { filesAlreadyInSync: number; coversUploaded: number; booksDownloaded: number; + /** + * Number of already-local books whose metadata (title / author / cover) + * was refreshed from a newer copy in the shared library.json (#4756). + */ + metadataUpdated: number; failures: number; /** * Per-book failure breakdown for the diagnostic log surfaced in the @@ -641,6 +668,15 @@ export interface SyncLibraryOptions { */ saveBookConfig?: (book: Book, config: BookConfig) => Promise; addBookToLibrary?: (book: Book) => Promise; + /** + * Persist refreshed metadata for a book that already exists in the local + * library. Called once per book whose copy in the shared library.json is + * strictly newer than the local one (last-writer-wins on `book.updatedAt`), + * so a peer's title / author / cover edit propagates to devices that + * already hold the book. Distinct from `addBookToLibrary`, which only + * inserts brand-new rows and no-ops on an existing hash. + */ + updateBookMetadata?: (book: Book) => Promise; /** Stable per-device id; written into every config envelope. */ deviceId: string; /** @@ -721,6 +757,7 @@ export const syncLibrary = async ( filesAlreadyInSync: 0, coversUploaded: 0, booksDownloaded: 0, + metadataUpdated: 0, failures: 0, failedBooks: [], }; @@ -751,6 +788,45 @@ export const syncLibrary = async ( // listing the hash dir. const explicitRemotePaths = new Map(); + // Metadata reconciliation for books present BOTH locally and in the shared + // library.json (#4756). Last-writer-wins on `book.updatedAt`: when a peer's + // indexed copy is strictly newer, pull its title / author / cover down to + // this device. Without this, a device that already holds the book never + // learns about a peer's metadata edit, AND the index re-push at the end of + // syncLibrary would clobber the peer's newer metadata with this device's + // stale copy (allBooksMap still pointed at the local book). Updating + // allBooksMap with the merged copy fixes both directions at once. + if (canPull && remoteIndex && remoteIndex.books && options.updateBookMetadata) { + for (const rb of remoteIndex.books) { + if (rb.deletedAt) continue; + const local = allBooksMap.get(rb.hash); + if (!local || local.deletedAt) continue; + if ((rb.updatedAt ?? 0) <= (local.updatedAt ?? 0)) continue; + const merged = mergeRemoteBookMetadata(local, rb); + // Re-pull the cover so a changed cover travels with the metadata. The + // cover is best-effort: a missing remote cover.png simply leaves the + // existing local cover in place. The subsequent push-side pushBookCover + // HEAD/size short-circuit then matches (local now equals remote), so we + // never bounce the freshly-pulled cover back up. + if (options.saveBookCover) { + try { + const coverBytes = await pullBookCover(settings, rb.hash); + if (coverBytes) await options.saveBookCover(merged, coverBytes); + } catch (e) { + console.warn('WD library sync: metadata cover pull failed', rb.hash, e); + } + } + try { + await options.updateBookMetadata(merged); + // Keep the merged metadata authoritative for the index re-push below. + allBooksMap.set(rb.hash, merged); + result.metadataUpdated += 1; + } catch (e) { + console.warn('WD library sync: metadata update failed', rb.hash, e); + } + } + } + if (canPull) { const client = toClientConfig(settings); const candidateHashes = new Set(); @@ -938,7 +1014,30 @@ export const syncLibrary = async ( try { const config = await options.loadConfig(book); if (config) { - await pushBookConfig(settings, book, config, options.deviceId); + // Mirror the reader hook's pull-merge-push discipline so a manual + // "Sync now" can't blind-overwrite state this device hasn't pulled + // yet: a peer's booknotes (element-set CRDT) or newer progress + // (per-config LWW). Only in two-way ('silent') mode — 'send' + // deliberately treats the local copy as authoritative and keeps the + // blind push. A failed pull-merge falls back to the local config so + // a flaky GET never blocks the book's sync. + let configToPush = config; + if (canPull) { + try { + const pull = await pullBookConfig(settings, book, config); + if (pull.applied && pull.mergedConfig) { + configToPush = pull.mergedConfig; + // Persist the merged superset locally so this device converges + // too, not just the remote. + if (options.saveBookConfig) { + await options.saveBookConfig(book, pull.mergedConfig); + } + } + } catch (e) { + console.warn('WD library sync: config pull-merge failed', book.hash, e); + } + } + await pushBookConfig(settings, book, configToPush, options.deviceId); result.configsUploaded += 1; } // Covers ride along with the config-level sync, NOT with diff --git a/packages/foliate-js b/packages/foliate-js index 23ec1c79..982f168c 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 23ec1c7957af6a5dcbd7d340314fe5238ef4e665 +Subproject commit 982f168c668dedcf38b2304bec788337712d60aa