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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-25 12:50:34 +08:00
committed by GitHub
parent e80ab1762b
commit cd3a53f507
37 changed files with 527 additions and 35 deletions
@@ -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": "تم تحديث البيانات الوصفية"
}
@@ -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": "মেটাডেটা আপডেট হয়েছে"
}
@@ -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": "གནད་སྨིན་གོ་དོན་གསར་བསྒྱུར་བྱས།"
}
@@ -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"
}
@@ -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": "Ενημερωμένα μεταδεδομένα"
}
@@ -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"
}
@@ -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": "متادیتا به‌روزرسانی شد"
}
@@ -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"
}
@@ -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": "המטא-נתונים עודכנו"
}
@@ -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": "मेटाडेटा अपडेट किया गया"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": "更新したメタデータ"
}
@@ -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": "메타데이터 업데이트됨"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": "Метаданные обновлены"
}
@@ -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": "මෙටාඩේටා යාවත්කාලීන කරන ලදී"
}
@@ -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"
}
@@ -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"
}
@@ -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": "மெட்டாடேட்டா புதுப்பிக்கப்பட்டது"
}
@@ -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": "อัปเดตเมทาดาตาแล้ว"
}
@@ -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"
}
@@ -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": "Метадані оновлено"
}
@@ -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"
}
@@ -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"
}
@@ -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": "已更新元数据"
}
@@ -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": "已更新中繼資料"
}
@@ -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<typeof import('@/services/webdav/WebDAVClient')>();
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> = {}): 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<typeof vi.fn>).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<typeof vi.fn>).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 <hash>/config.json. */
const capturePushedConfig = (): { value: RemoteBookConfig | null } => {
const captured: { value: RemoteBookConfig | null } = { value: null };
(putFile as ReturnType<typeof vi.fn>).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> = {}): RemoteBookConfig => ({
schemaVersion: 1,
bookHash: 'h1',
config: { updatedAt: 100 },
booknotes: [],
writerDeviceId: 'mobile',
writerVersion: 'readest-webdav-1',
updatedAt: 100,
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
(listDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getFileBinary as ReturnType<typeof vi.fn>).mockResolvedValue(new ArrayBuffer(8));
(headFile as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(putFile as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(putFileBinary as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
(ensureDirectory as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
const baseOptions = () => ({
strategy: 'silent' as const,
syncBooks: false,
deviceId: 'pc-device',
loadConfig: async (): Promise<BookConfig> => ({ 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<BookConfig> => ({
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<BookConfig> => ({
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<BookConfig> => ({
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']);
});
});
@@ -410,6 +410,20 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ 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<WebDAVFormProps> = ({ 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 }));
}
@@ -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<void>;
addBookToLibrary?: (book: Book) => Promise<void>;
/**
* 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<void>;
/** 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<string, string>();
// 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<string>();
@@ -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