feat: Implement KOReader Progress Synchronization (#1770)

* feat(sync): implement KOReader progress synchronization

This commit introduces a comprehensive feature to synchronize reading progress with a KOReader sync server. It includes a compatibility layer to handle discrepancies between Readest's CFI-based progress and KOReader's XPointer/page-based progress, primarily using the `percentage` field as a common ground.

Key additions include:
- A new settings panel under "KOReader Sync" for server configuration, authentication, and sync strategy management (e.g., prompt on conflict, always use latest).
- A conflict resolution dialog that appears when remote progress differs significantly from local progress, allowing the user to choose which version to keep.
- A client-side `useKOSync` hook to manage the entire synchronization lifecycle, including API calls, state management, and conflict resolution logic.
- A new API endpoint `/api/kosync` that acts as a secure proxy to the user-configured KOReader sync server, handling authentication and forwarding requests.
- Logic to differentiate between paginated (PDF/CBZ) and reflowable (EPUB) formats, using page numbers for paginated files where possible and falling back to percentage for reliability.
- Spanish translations for all UI elements related to the KOReader sync feature.
- Addition of `uuid` package to generate a unique `device_id` for sync purposes.

Refactor:
- The `debounce` utility has been improved to include `flush` and `cancel` methods, allowing for more precise control over debounced function execution, which is now used in the sync hook.

* fix(kosync): add support for converting between XPointer and CFI in progress synchronization

* fix(kosync): update navigation method to use select instead of goTo for paginated formats

* fix(kosync): refactor synchronization settings and improve conflict resolution handling

* fix(kosync): add event dispatcher for flushing KOReader synchronization

* fix(sync): handle xpointer in a different section, fix styling

* i18n: update translations

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
Zeedif 2025-08-11 09:53:28 -06:00 committed by GitHub
parent b8bb1ee71d
commit 595608bd62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2006 additions and 34 deletions

View file

@ -5,5 +5,6 @@
"tabWidth": 2,
"singleQuote": true,
"jsxSingleQuote": true,
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}

View file

@ -98,6 +98,7 @@
"semver": "^7.7.1",
"stripe": "^18.2.1",
"tinycolor2": "^1.6.0",
"uuid": "^11.1.0",
"zod": "^4.0.8",
"zustand": "5.0.6"
},
@ -116,6 +117,7 @@
"@types/react-window": "^1.8.8",
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",

View file

@ -487,5 +487,38 @@
"Are you sure to delete the local copy of the selected book?": "هل أنت متأكد من حذف النسخة المحلية للكتاب المحدد؟",
"Remove from Cloud & Device": "إزالة من السحابة والجهاز",
"Remove from Cloud Only": "إزالة من السحابة فقط",
"Remove from Device Only": "إزالة من الجهاز فقط"
"Remove from Device Only": "إزالة من الجهاز فقط",
"Error": "خطأ",
"Disconnected": "غير متصل",
"KOReader Sync Settings": "إعدادات مزامنة KOReader",
"Connected as {{username}}": "متصل كـ {{username}}",
"Disconnect": "قطع الاتصال",
"Sync Strategy": "استراتيجية المزامنة",
"Ask on conflict": "اسأل عند حدوث تعارض",
"Always use latest": "استخدام الأحدث دائمًا",
"Send changes only": "إرسال التغييرات فقط",
"Receive changes only": "استلام التغييرات فقط",
"Disabled": "معطل",
"Checksum Method": "طريقة التحقق من الصحة",
"File Content (recommended)": "محتوى الملف (موصى به)",
"File Name": "اسم الملف",
"Device Name": "اسم الجهاز",
"Sync Tolerance": "تحمل المزامنة",
"Precision: {{precision}} decimal places": "الدقة: {{precision}} منزلة عشرية",
"Connect to your KOReader Sync server.": "الاتصال بخادم مزامنة KOReader الخاص بك.",
"Server URL": "عنوان URL للخادم",
"Username": "اسم المستخدم",
"Your Username": "اسم المستخدم الخاص بك",
"Password": "كلمة المرور",
"Connect": "الاتصال",
"KOReader Sync": "مزامنة KOReader",
"Sync Conflict": "تعارض المزامنة",
"Sync reading progress from \"{{deviceName}}\"?": "مزامنة تقدم القراءة من \"{{deviceName}}\"؟",
"another device": "جهاز آخر",
"Local Progress": "التقدم المحلي",
"Remote Progress": "التقدم البعيد",
"Page {{page}} of {{total}} ({{percentage}}%)": "الصفحة {{page}} من {{total}} ({{percentage}}%)",
"Current position": "الموقع الحالي",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "تقريبًا الصفحة {{page}} من {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "تقريبًا {{percentage}}%"
}

View file

@ -471,5 +471,38 @@
"Are you sure to delete the local copy of the selected book?": "Sind Sie sicher, dass Sie die lokale Kopie des ausgewählten Buches löschen möchten?",
"Remove from Cloud & Device": "Aus Cloud & Gerät entfernen",
"Remove from Cloud Only": "Nur aus der Cloud entfernen",
"Remove from Device Only": "Nur vom Gerät entfernen"
"Remove from Device Only": "Nur vom Gerät entfernen",
"Error": "Fehler",
"Disconnected": "Getrennt",
"KOReader Sync Settings": "KOReader Sync-Einstellungen",
"Connected as {{username}}": "Verbunden als {{username}}",
"Disconnect": "Trennen",
"Sync Strategy": "Sync-Strategie",
"Ask on conflict": "Bei Konflikten fragen",
"Always use latest": "Immer die neueste Version verwenden",
"Send changes only": "Nur Änderungen senden",
"Receive changes only": "Nur Änderungen empfangen",
"Disabled": "Deaktiviert",
"Checksum Method": "Prüfziffernverfahren",
"File Content (recommended)": "Dateiinhalte (empfohlen)",
"File Name": "Dateiname",
"Device Name": "Gerätename",
"Sync Tolerance": "Sync-Toleranz",
"Precision: {{precision}} decimal places": "Präzision: {{precision}} Dezimalstellen",
"Connect to your KOReader Sync server.": "Mit Ihrem KOReader Sync-Server verbinden.",
"Server URL": "Server-URL",
"Username": "Benutzername",
"Your Username": "Ihr Benutzername",
"Password": "Passwort",
"Connect": "Verbinden",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Sync-Konflikt",
"Sync reading progress from \"{{deviceName}}\"?": "Lesefortschritt von \"{{deviceName}}\" synchronisieren?",
"another device": "ein anderes Gerät",
"Local Progress": "Lokaler Fortschritt",
"Remote Progress": "Entfernter Fortschritt",
"Page {{page}} of {{total}} ({{percentage}}%)": "Seite {{page}} von {{total}} ({{percentage}}%)",
"Current position": "Aktuelle Position",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Ungefähr Seite {{page}} von {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Ungefähr {{percentage}}%"
}

View file

@ -471,5 +471,38 @@
"Are you sure to delete the local copy of the selected book?": "Είστε σίγουροι ότι θέλετε να διαγράψετε την τοπική αντιγραφή του επιλεγμένου βιβλίου;",
"Remove from Cloud & Device": "Αφαίρεση από το Cloud & Device",
"Remove from Cloud Only": "Αφαίρεση μόνο από το Cloud",
"Remove from Device Only": "Αφαίρεση μόνο από το Device"
"Remove from Device Only": "Αφαίρεση μόνο από το Device",
"Error": "Σφάλμα",
"Disconnected": "Αποσυνδεδεμένο",
"KOReader Sync Settings": "Ρυθμίσεις συγχρονισμού KOReader",
"Connected as {{username}}": "Συνδεδεμένος ως {{username}}",
"Disconnect": "Αποσύνδεση",
"Sync Strategy": "Στρατηγική συγχρονισμού",
"Ask on conflict": "Ρώτησε σε περίπτωση σύγκρουσης",
"Always use latest": "Χρησιμοποίησε πάντα την τελευταία έκδοση",
"Send changes only": "Αποστολή μόνο αλλαγών",
"Receive changes only": "Λήψη μόνο αλλαγών",
"Disabled": "Απενεργοποιημένο",
"Checksum Method": "Μέθοδος ελέγχου",
"File Content (recommended)": "Περιεχόμενο αρχείου (συνιστάται)",
"File Name": "Όνομα αρχείου",
"Device Name": "Όνομα συσκευής",
"Sync Tolerance": "Ανοχή συγχρονισμού",
"Precision: {{precision}} decimal places": "Ακρίβεια: {{precision}} δεκαδικά ψηφία",
"Connect to your KOReader Sync server.": "Συνδεθείτε στον διακομιστή συγχρονισμού KOReader.",
"Server URL": "Διεύθυνση URL διακομιστή",
"Username": "Όνομα χρήστη",
"Your Username": "Το όνομα χρήστη σας",
"Password": "Κωδικός πρόσβασης",
"Connect": "Σύνδεση",
"KOReader Sync": "Συγχρονισμός KOReader",
"Sync Conflict": "Σύγκρουση συγχρονισμού",
"Sync reading progress from \"{{deviceName}}\"?": "Συγχρονίστε την πρόοδο ανάγνωσης από το \"{{deviceName}}\";",
"another device": "άλλη συσκευή",
"Local Progress": "Τοπική πρόοδος",
"Remote Progress": "Απομακρυσμένη πρόοδος",
"Page {{page}} of {{total}} ({{percentage}}%)": "Σελίδα {{page}} από {{total}} ({{percentage}}%)",
"Current position": "Τρέχουσα θέση",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Περίπου σελίδα {{page}} από {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Περίπου {{percentage}}%"
}

View file

@ -69,7 +69,40 @@
"Parallel Read": "Lectura paralela",
"Published": "Publicado",
"Publisher": "Editorial",
"KOReader Sync": "Sincronización con KOReader",
"KOReader Sync Settings": "Ajustes de Sincronización con KOReader",
"Your Username": "Tu nombre de usuario",
"Connect to your KOReader Sync server.": "Conéctate a tu servidor de KOReader Sync.",
"Connected as {{username}}": "Conectado como {{username}}",
"Server URL": "URL del Servidor",
"Username": "Usuario",
"Password": "Contraseña",
"Connect": "Conectar",
"Disconnect": "Desconectar",
"Disconnected": "Desconectado",
"Error": "Error",
"Sync Strategy": "Estrategia de sincronización",
"Ask on conflict": "Preguntar en caso de conflicto",
"Always use latest": "Usar siempre el más reciente",
"Send changes only": "Solo enviar cambios",
"Receive changes only": "Solo recibir cambios",
"Disabled": "Desactivado",
"Checksum Method": "Método de identificación",
"File Content (recommended)": "Contenido del archivo (recomendado)",
"File Name": "Nombre del archivo",
"Device Name": "Nombre del dispositivo",
"Sync Tolerance": "Tolerancia de sincronización",
"Precision: {{precision}} decimal places": "Precisión: {{precision}} decimales",
"Reading Progress Synced": "Progreso de lectura sincronizado",
"Sync Conflict": "Conflicto de sincronización",
"Sync reading progress from \"{{deviceName}}\"?": "¿Quieres sincronizar el progreso de lectura desde el dispositivo \"{{deviceName}}\"?",
"Local Progress": "Progreso local",
"Remote Progress": "Progreso remoto",
"Page {{page}} of {{total}} ({{percentage}}%)": "Página {{page}} de {{total}} ({{percentage}}%)",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Aproximadamente página {{page}} de {{total}} ({{percentage}}%)",
"Current position": "Posición actual",
"Approximately {{percentage}}%": "Aproximadamente {{percentage}}%",
"another device": "otro dispositivo",
"Reload Page": "Recargar página",
"Reveal in File Explorer": "Mostrar en el Explorador de archivos",
"Reveal in Finder": "Mostrar en Finder",

View file

@ -475,5 +475,38 @@
"Are you sure to delete the local copy of the selected book?": "Êtes-vous sûr de vouloir supprimer la copie locale du livre sélectionné ?",
"Remove from Cloud & Device": "Supprimer du Cloud & de l'appareil",
"Remove from Cloud Only": "Supprimer uniquement du Cloud",
"Remove from Device Only": "Supprimer uniquement de l'appareil"
"Remove from Device Only": "Supprimer uniquement de l'appareil",
"Error": "Erreur",
"Disconnected": "Déconnecté",
"KOReader Sync Settings": "Paramètres de synchronisation KOReader",
"Connected as {{username}}": "Connecté en tant que {{username}}",
"Disconnect": "Déconnecter",
"Sync Strategy": "Stratégie de synchronisation",
"Ask on conflict": "Demander en cas de conflit",
"Always use latest": "Toujours utiliser la dernière version",
"Send changes only": "Envoyer uniquement les modifications",
"Receive changes only": "Recevoir uniquement les modifications",
"Disabled": "Désactivé",
"Checksum Method": "Méthode de somme de contrôle",
"File Content (recommended)": "Contenu du fichier (recommandé)",
"File Name": "Nom du fichier",
"Device Name": "Nom de l'appareil",
"Sync Tolerance": "Tolérance de synchronisation",
"Precision: {{precision}} decimal places": "Précision : {{precision}} décimales",
"Connect to your KOReader Sync server.": "Connectez-vous à votre serveur de synchronisation KOReader.",
"Server URL": "URL du serveur",
"Username": "Nom d'utilisateur",
"Your Username": "Votre nom d'utilisateur",
"Password": "Mot de passe",
"Connect": "Connecter",
"KOReader Sync": "Synchronisation KOReader",
"Sync Conflict": "Conflit de synchronisation",
"Sync reading progress from \"{{deviceName}}\"?": "Synchroniser la progression de lecture depuis \"{{deviceName}}\"?",
"another device": "un autre appareil",
"Local Progress": "Progression locale",
"Remote Progress": "Progression distante",
"Page {{page}} of {{total}} ({{percentage}}%)": "Page {{page}} sur {{total}} ({{percentage}}%)",
"Current position": "Position actuelle",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Environ page {{page}} sur {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Environ {{percentage}}%"
}

View file

@ -471,5 +471,38 @@
"Are you sure to delete the local copy of the selected book?": "क्या आप सुनिश्चित हैं कि आप चयनित पुस्तक की स्थानीय प्रति हटाना चाहते हैं?",
"Remove from Cloud & Device": "क्लाउड और डिवाइस से हटाएँ",
"Remove from Cloud Only": "केवल क्लाउड से हटाएँ",
"Remove from Device Only": "केवल डिवाइस से हटाएँ"
"Remove from Device Only": "केवल डिवाइस से हटाएँ",
"Error": "त्रुटि",
"Disconnected": "अविचलित",
"KOReader Sync Settings": "KOReader सिंक सेटिंग्स",
"Connected as {{username}}": "जुड़ा हुआ {{username}} के रूप में",
"Disconnect": "अविचलित करें",
"Sync Strategy": "सिंक रणनीति",
"Ask on conflict": "संघर्ष पर पूछें",
"Always use latest": "हमेशा नवीनतम का उपयोग करें",
"Send changes only": "केवल परिवर्तन भेजें",
"Receive changes only": "केवल परिवर्तन प्राप्त करें",
"Disabled": "अक्षम",
"Checksum Method": "चेकसम विधि",
"File Content (recommended)": "फाइल सामग्री (अनुशंसित)",
"File Name": "फाइल नाम",
"Device Name": "डिवाइस नाम",
"Sync Tolerance": "सिंक सहिष्णुता",
"Precision: {{precision}} decimal places": "सटीकता: {{precision}} दशमलव स्थान",
"Connect to your KOReader Sync server.": "अपने KOReader सिंक सर्वर से कनेक्ट करें।",
"Server URL": "सर्वर यूआरएल",
"Username": "उपयोगकर्ता नाम",
"Your Username": "आपका उपयोगकर्ता नाम",
"Password": "पासवर्ड",
"Connect": "कनेक्ट करें",
"KOReader Sync": "KOReader सिंक",
"Sync Conflict": "सिंक संघर्ष",
"Sync reading progress from \"{{deviceName}}\"?": "क्या आप \"{{deviceName}}\" से पढ़ने की प्रगति को सिंक करना चाहते हैं?",
"another device": "दूसरा डिवाइस",
"Local Progress": "स्थानीय प्रगति",
"Remote Progress": "दूरस्थ प्रगति",
"Page {{page}} of {{total}} ({{percentage}}%)": "पृष्ठ {{page}} कुल {{total}} ({{percentage}}%)",
"Current position": "वर्तमान स्थिति",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "लगभग पृष्ठ {{page}} कुल {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "लगभग {{percentage}}%"
}

View file

@ -462,11 +462,43 @@
"Page Number": "Nomor Halaman",
"Percentage": "Persentase",
"Deleted local copy of the book: {{title}}": "Salinan lokal buku dihapus: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Gagal menghapus cadangan cloud buku: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Gagal menghapus salinan lokal buku: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Apakah Anda yakin ingin menghapus salinan lokal buku yang dipilih?",
"Remove from Cloud & Device": "Hapus dari Cloud & Perangkat",
"Remove from Cloud Only": "Hapus dari Cloud Saja",
"Remove from Device Only": "Hapus dari Perangkat Saja"
"Remove from Device Only": "Hapus dari Perangkat Saja",
"Error": "Kesalahan",
"Disconnected": "Terputus",
"KOReader Sync Settings": "Pengaturan Sinkronisasi KOReader",
"Connected as {{username}}": "Terhubung sebagai {{username}}",
"Disconnect": "Putuskan Koneksi",
"Sync Strategy": "Strategi Sinkronisasi",
"Ask on conflict": "Tanya saat terjadi konflik",
"Always use latest": "Selalu gunakan yang terbaru",
"Send changes only": "Kirim perubahan saja",
"Receive changes only": "Terima perubahan saja",
"Disabled": "Dinonaktifkan",
"Checksum Method": "Metode Checksum",
"File Content (recommended)": "Konten File (disarankan)",
"File Name": "Nama File",
"Device Name": "Nama Perangkat",
"Sync Tolerance": "Toleransi Sinkronisasi",
"Precision: {{precision}} decimal places": "Presisi: {{precision}} tempat desimal",
"Connect to your KOReader Sync server.": "Hubungkan ke server Sinkronisasi KOReader Anda.",
"Server URL": "URL Server",
"Username": "Nama Pengguna",
"Your Username": "Nama Pengguna Anda",
"Password": "Kata Sandi",
"Connect": "Hubungkan",
"KOReader Sync": "Sinkronisasi KOReader",
"Sync Conflict": "Konflik Sinkronisasi",
"Sync reading progress from \"{{deviceName}}\"?": "Sinkronkan progres membaca dari \"{{deviceName}}\"?",
"another device": "perangkat lain",
"Local Progress": "Progres Lokal",
"Remote Progress": "Progres Jarak Jauh",
"Page {{page}} of {{total}} ({{percentage}}%)": "Halaman {{page}} dari {{total}} ({{percentage}}%)",
"Current position": "Posisi Saat Ini",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Sekitar halaman {{page}} dari {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Sekitar {{percentage}}%"
}

View file

@ -475,5 +475,38 @@
"Are you sure to delete the local copy of the selected book?": "Sei sicuro di voler eliminare la copia locale del libro selezionato?",
"Remove from Cloud & Device": "Rimuovi da Cloud & Dispositivo",
"Remove from Cloud Only": "Rimuovi solo da Cloud",
"Remove from Device Only": "Rimuovi solo da Dispositivo"
"Remove from Device Only": "Rimuovi solo da Dispositivo",
"Error": "Errore",
"Disconnected": "Disconnesso",
"KOReader Sync Settings": "Impostazioni di Sincronizzazione KOReader",
"Connected as {{username}}": "Connesso come {{username}}",
"Disconnect": "Disconnetti",
"Sync Strategy": "Strategia di Sincronizzazione",
"Ask on conflict": "Chiedi in caso di conflitto",
"Always use latest": "Usa sempre l'ultima versione",
"Send changes only": "Invia solo le modifiche",
"Receive changes only": "Ricevi solo le modifiche",
"Disabled": "Disabilitato",
"Checksum Method": "Metodo di Controllo",
"File Content (recommended)": "Contenuto del File (consigliato)",
"File Name": "Nome del File",
"Device Name": "Nome del Dispositivo",
"Sync Tolerance": "Tolleranza di Sincronizzazione",
"Precision: {{precision}} decimal places": "Precisione: {{precision}} posizioni decimali",
"Connect to your KOReader Sync server.": "Connettiti al tuo server KOReader Sync.",
"Server URL": "URL del Server",
"Username": "Nome Utente",
"Your Username": "Il tuo Nome Utente",
"Password": "Password",
"Connect": "Connetti",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Conflitto di Sincronizzazione",
"Sync reading progress from \"{{deviceName}}\"?": "Sincronizzare il progresso di lettura da \"{{deviceName}}\"?",
"another device": "un altro dispositivo",
"Local Progress": "Progresso Locale",
"Remote Progress": "Progresso Remoto",
"Page {{page}} of {{total}} ({{percentage}}%)": "Pagina {{page}} di {{total}} ({{percentage}}%)",
"Current position": "Posizione Corrente",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Circa pagina {{page}} di {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Circa {{percentage}}%"
}

View file

@ -467,5 +467,38 @@
"Are you sure to delete the local copy of the selected book?": "選択した書籍のローカルコピーを削除してもよろしいですか?",
"Remove from Cloud & Device": "クラウドとデバイスから削除",
"Remove from Cloud Only": "クラウドからのみ削除",
"Remove from Device Only": "デバイスからのみ削除"
"Remove from Device Only": "デバイスからのみ削除",
"Error": "エラー",
"Disconnected": "切断されました",
"KOReader Sync Settings": "KOReader Sync設定",
"Connected as {{username}}": "{{username}}として接続されています",
"Disconnect": "切断",
"Sync Strategy": "同期戦略",
"Ask on conflict": "競合時に確認",
"Always use latest": "最新を常に使用",
"Send changes only": "変更のみを送信",
"Receive changes only": "変更のみを受信",
"Disabled": "無効",
"Checksum Method": "チェックサム方式",
"File Content (recommended)": "ファイル内容(推奨)",
"File Name": "ファイル名",
"Device Name": "デバイス名",
"Sync Tolerance": "同期許容範囲",
"Precision: {{precision}} decimal places": "精度: {{precision}} 小数点以下",
"Connect to your KOReader Sync server.": "KOReader Syncサーバーに接続します。",
"Server URL": "サーバーURL",
"Username": "ユーザー名",
"Your Username": "あなたのユーザー名",
"Password": "パスワード",
"Connect": "接続",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "同期競合",
"Sync reading progress from \"{{deviceName}}\"?": "{{deviceName}}から読書進捗を同期しますか?",
"another device": "別のデバイス",
"Local Progress": "ローカル進捗",
"Remote Progress": "リモート進捗",
"Page {{page}} of {{total}} ({{percentage}}%)": "ページ {{page}} / {{total}} ({{percentage}}%)",
"Current position": "現在の位置",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "おおよそページ {{page}} / {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "おおよそ {{percentage}}%"
}

View file

@ -467,5 +467,38 @@
"Are you sure to delete the local copy of the selected book?": "선택한 책의 로컬 사본을 삭제하시겠습니까?",
"Remove from Cloud & Device": "클라우드 및 장치에서 제거",
"Remove from Cloud Only": "클라우드에서만 제거",
"Remove from Device Only": "장치에서만 제거"
"Remove from Device Only": "장치에서만 제거",
"Error": "오류",
"Disconnected": "연결 끊김",
"KOReader Sync Settings": "KOReader Sync 설정",
"Connected as {{username}}": "{{username}}로 연결됨",
"Disconnect": "연결 끊기",
"Sync Strategy": "동기화 전략",
"Ask on conflict": "충돌 시 묻기",
"Always use latest": "항상 최신 버전 사용",
"Send changes only": "변경 사항만 전송",
"Receive changes only": "변경 사항만 수신",
"Disabled": "비활성화",
"Checksum Method": "체크섬 방법",
"File Content (recommended)": "파일 내용 (권장)",
"File Name": "파일 이름",
"Device Name": "장치 이름",
"Sync Tolerance": "동기화 허용 오차",
"Precision: {{precision}} decimal places": "정밀도: {{precision}} 소수 자리",
"Connect to your KOReader Sync server.": "KOReader Sync 서버에 연결합니다.",
"Server URL": "서버 URL",
"Username": "사용자 이름",
"Your Username": "당신의 사용자 이름",
"Password": "비밀번호",
"Connect": "연결",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "동기화 충돌",
"Sync reading progress from \"{{deviceName}}\"?": " \"{{deviceName}}\"에서 읽기 진행 상황을 동기화하시겠습니까?",
"another device": "다른 장치",
"Local Progress": "로컬 진행 상황",
"Remote Progress": "원격 진행 상황",
"Page {{page}} of {{total}} ({{percentage}}%)": "페이지 {{page}} / {{total}} ({{percentage}}%)",
"Current position": "현재 위치",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "대략 페이지 {{page}} / {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "대략 {{percentage}}%"
}

View file

@ -471,5 +471,38 @@
"Are you sure to delete the local copy of the selected book?": "Weet je zeker dat je de lokale kopie van het geselecteerde boek wilt verwijderen?",
"Remove from Cloud & Device": "Verwijder uit Cloud & Apparaat",
"Remove from Cloud Only": "Verwijder alleen uit Cloud",
"Remove from Device Only": "Verwijder alleen uit Apparaat"
"Remove from Device Only": "Verwijder alleen uit Apparaat",
"Error": "Fout",
"Disconnected": "Verbroken",
"KOReader Sync Settings": "KOReader Sync-instellingen",
"Connected as {{username}}": "Verbonden als {{username}}",
"Disconnect": "Verbreken",
"Sync Strategy": "Synchronisatiestrategie",
"Ask on conflict": "Vraag bij conflict",
"Always use latest": "Altijd de nieuwste gebruiken",
"Send changes only": "Verzend alleen wijzigingen",
"Receive changes only": "Ontvang alleen wijzigingen",
"Disabled": "Uitgeschakeld",
"Checksum Method": "Checksum-methode",
"File Content (recommended)": "Bestandsinhoud (aanbevolen)",
"File Name": "Bestandsnaam",
"Device Name": "Apparaatnaam",
"Sync Tolerance": "Synchronisatietolerantie",
"Precision: {{precision}} decimal places": "Precisie: {{precision}} decimalen",
"Connect to your KOReader Sync server.": "Verbind met je KOReader Sync-server.",
"Server URL": "Server-URL",
"Username": "Gebruikersnaam",
"Your Username": "Je gebruikersnaam",
"Password": "Wachtwoord",
"Connect": "Verbinden",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Synchronisatieconflict",
"Sync reading progress from \"{{deviceName}}\"?": "Leesvoortgang synchroniseren van \"{{deviceName}}\"?",
"another device": "een ander apparaat",
"Local Progress": "Lokale voortgang",
"Remote Progress": "Externe voortgang",
"Page {{page}} of {{total}} ({{percentage}}%)": "Pagina {{page}} van {{total}} ({{percentage}}%)",
"Current position": "Huidige positie",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Ongeveer pagina {{page}} van {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Ongeveer {{percentage}}%"
}

View file

@ -479,5 +479,38 @@
"Are you sure to delete the local copy of the selected book?": "Czy na pewno chcesz usunąć lokalną kopię wybranej książki?",
"Remove from Cloud & Device": "Usuń z chmury i urządzenia",
"Remove from Cloud Only": "Usuń tylko z chmury",
"Remove from Device Only": "Usuń tylko z urządzenia"
"Remove from Device Only": "Usuń tylko z urządzenia",
"Error": "Błąd",
"Disconnected": "Rozłączono",
"KOReader Sync Settings": "Ustawienia synchronizacji KOReader",
"Connected as {{username}}": "Połączono jako {{username}}",
"Disconnect": "Rozłącz",
"Sync Strategy": "Strategia synchronizacji",
"Ask on conflict": "Pytaj w przypadku konfliktu",
"Always use latest": "Zawsze używaj najnowszej",
"Send changes only": "Wyślij tylko zmiany",
"Receive changes only": "Odbierz tylko zmiany",
"Disabled": "Wyłączone",
"Checksum Method": "Metoda sumy kontrolnej",
"File Content (recommended)": "Zawartość pliku (zalecane)",
"File Name": "Nazwa pliku",
"Device Name": "Nazwa urządzenia",
"Sync Tolerance": "Tolerancja synchronizacji",
"Precision: {{precision}} decimal places": "Precyzja: {{precision}} miejsc po przecinku",
"Connect to your KOReader Sync server.": "Połącz z serwerem synchronizacji KOReader.",
"Server URL": "Adres URL serwera",
"Username": "Nazwa użytkownika",
"Your Username": "Twoja nazwa użytkownika",
"Password": "Hasło",
"Connect": "Połącz",
"KOReader Sync": "Synchronizacja KOReader",
"Sync Conflict": "Konflikt synchronizacji",
"Sync reading progress from \"{{deviceName}}\"?": "Synchronizować postęp czytania z \"{{deviceName}}\"?",
"another device": "inne urządzenie",
"Local Progress": "Postęp lokalny",
"Remote Progress": "Postęp zdalny",
"Page {{page}} of {{total}} ({{percentage}}%)": "Strona {{page}} z {{total}} ({{percentage}}%)",
"Current position": "Bieżąca pozycja",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Około strona {{page}} z {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Około {{percentage}}%"
}

View file

@ -475,5 +475,38 @@
"Are you sure to delete the local copy of the selected book?": "Tem certeza de que deseja excluir a cópia local do livro selecionado?",
"Remove from Cloud & Device": "Remover da Nuvem & Dispositivo",
"Remove from Cloud Only": "Remover apenas da Nuvem",
"Remove from Device Only": "Remover apenas do Dispositivo"
"Remove from Device Only": "Remover apenas do Dispositivo",
"Error": "Erro",
"Disconnected": "Desconectado",
"KOReader Sync Settings": "Configurações de Sincronização KOReader",
"Connected as {{username}}": "Conectado como {{username}}",
"Disconnect": "Desconectar",
"Sync Strategy": "Estratégia de Sincronização",
"Ask on conflict": "Perguntar em caso de conflito",
"Always use latest": "Sempre usar o mais recente",
"Send changes only": "Enviar apenas alterações",
"Receive changes only": "Receber apenas alterações",
"Disabled": "Desativado",
"Checksum Method": "Método de Verificação",
"File Content (recommended)": "Conteúdo do Arquivo (recomendado)",
"File Name": "Nome do Arquivo",
"Device Name": "Nome do Dispositivo",
"Sync Tolerance": "Tolerância de Sincronização",
"Precision: {{precision}} decimal places": "Precisão: {{precision}} casas decimais",
"Connect to your KOReader Sync server.": "Conectar ao seu servidor KOReader Sync.",
"Server URL": "URL do Servidor",
"Username": "Nome de Usuário",
"Your Username": "Seu Nome de Usuário",
"Password": "Senha",
"Connect": "Conectar",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Conflito de Sincronização",
"Sync reading progress from \"{{deviceName}}\"?": "Sincronizar progresso de leitura de \"{{deviceName}}\"?",
"another device": "outro dispositivo",
"Local Progress": "Progresso Local",
"Remote Progress": "Progresso Remoto",
"Page {{page}} of {{total}} ({{percentage}}%)": "Página {{page}} de {{total}} ({{percentage}}%)",
"Current position": "Posição Atual",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Aproximadamente página {{page}} de {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Aproximadamente {{percentage}}%"
}

View file

@ -479,5 +479,38 @@
"Are you sure to delete the local copy of the selected book?": "Вы уверены, что хотите удалить локальную копию выбранной книги?",
"Remove from Cloud & Device": "Удалить из облака и устройства",
"Remove from Cloud Only": "Удалить только из облака",
"Remove from Device Only": "Удалить только с устройства"
"Remove from Device Only": "Удалить только с устройства",
"Error": "Ошибка",
"Disconnected": "Отключено",
"KOReader Sync Settings": "Настройки синхронизации KOReader",
"Connected as {{username}}": "Подключено как {{username}}",
"Disconnect": "Отключить",
"Sync Strategy": "Стратегия синхронизации",
"Ask on conflict": "Спрашивать при конфликте",
"Always use latest": "Всегда использовать последнюю",
"Send changes only": "Отправлять только изменения",
"Receive changes only": "Получать только изменения",
"Disabled": "Отключено",
"Checksum Method": "Метод контрольной суммы",
"File Content (recommended)": "Содержимое файла (рекомендуется)",
"File Name": "Имя файла",
"Device Name": "Имя устройства",
"Sync Tolerance": "Допуск синхронизации",
"Precision: {{precision}} decimal places": "Точность: {{precision}} десятичных знаков",
"Connect to your KOReader Sync server.": "Подключитесь к вашему серверу синхронизации KOReader.",
"Server URL": "URL сервера",
"Username": "Имя пользователя",
"Your Username": "Ваше имя пользователя",
"Password": "Пароль",
"Connect": "Подключиться",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Конфликт синхронизации",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронизировать прогресс чтения с \"{{deviceName}}\"?",
"another device": "другом устройстве",
"Local Progress": "Локальный прогресс",
"Remote Progress": "Удаленный прогресс",
"Page {{page}} of {{total}} ({{percentage}}%)": "Страница {{page}} из {{total}} ({{percentage}}%)",
"Current position": "Текущая позиция",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизительно страница {{page}} из {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Приблизительно {{percentage}}%"
}

View file

@ -467,5 +467,38 @@
"Are you sure to delete the local copy of the selected book?": "คุณแน่ใจหรือไม่ว่าต้องการลบสำเนาในเครื่องของหนังสือที่เลือก?",
"Remove from Cloud & Device": "ลบจากคลาวด์และอุปกรณ์",
"Remove from Cloud Only": "ลบจากคลาวด์เท่านั้น",
"Remove from Device Only": "ลบจากอุปกรณ์เท่านั้น"
"Remove from Device Only": "ลบจากอุปกรณ์เท่านั้น",
"Error": "เกิดข้อผิดพลาด",
"Disconnected": "ตัดการเชื่อมต่อ",
"KOReader Sync Settings": "การตั้งค่าการซิงค์ KOReader",
"Connected as {{username}}": "เชื่อมต่อเป็น {{username}}",
"Disconnect": "ตัดการเชื่อมต่อ",
"Sync Strategy": "กลยุทธ์การซิงค์",
"Ask on conflict": "ถามเมื่อเกิดความขัดแย้ง",
"Always use latest": "ใช้เวอร์ชันล่าสุดเสมอ",
"Send changes only": "ส่งการเปลี่ยนแปลงเท่านั้น",
"Receive changes only": "รับการเปลี่ยนแปลงเท่านั้น",
"Disabled": "ปิดใช้งาน",
"Checksum Method": "วิธีการตรวจสอบความถูกต้อง",
"File Content (recommended)": "เนื้อหาไฟล์ (แนะนำ)",
"File Name": "ชื่อไฟล์",
"Device Name": "ชื่ออุปกรณ์",
"Sync Tolerance": "ความทนทานต่อการซิงค์",
"Precision: {{precision}} decimal places": "ความแม่นยำ: {{precision}} ตำแหน่งทศนิยม",
"Connect to your KOReader Sync server.": "เชื่อมต่อกับเซิร์ฟเวอร์ KOReader Sync ของคุณ",
"Server URL": "URL ของเซิร์ฟเวอร์",
"Username": "ชื่อผู้ใช้",
"Your Username": "ชื่อผู้ใช้ของคุณ",
"Password": "รหัสผ่าน",
"Connect": "เชื่อมต่อ",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "ความขัดแย้งในการซิงค์",
"Sync reading progress from \"{{deviceName}}\"?": "ซิงค์ความก้าวหน้าในการอ่านจาก \"{{deviceName}}\"?",
"another device": "อุปกรณ์อื่น",
"Local Progress": "ความก้าวหน้าในเครื่อง",
"Remote Progress": "ความก้าวหน้าในคลาวด์",
"Page {{page}} of {{total}} ({{percentage}}%)": "หน้า {{page}} จาก {{total}} ({{percentage}}%)",
"Current position": "ตำแหน่งปัจจุบัน",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ประมาณหน้า {{page}} จาก {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "ประมาณ {{percentage}}%"
}

View file

@ -471,5 +471,38 @@
"Are you sure to delete the local copy of the selected book?": "Seçilen kitabın yerel kopyasını silmek istediğinize emin misiniz?",
"Remove from Cloud & Device": "Buluttan ve Cihazdan Kaldır",
"Remove from Cloud Only": "Sadece Buluttan Kaldır",
"Remove from Device Only": "Sadece Cihazdan Kaldır"
"Remove from Device Only": "Sadece Cihazdan Kaldır",
"Error": "Hata",
"Disconnected": "Bağlantı Kesildi",
"KOReader Sync Settings": "KOReader Senkronizasyon Ayarları",
"Connected as {{username}}": "{{username}} olarak bağlandı",
"Disconnect": "Bağlantıyı Kes",
"Sync Strategy": "Senkronizasyon Stratejisi",
"Ask on conflict": "Çatışmada Sor",
"Always use latest": "Her Zaman En Sonunu Kullan",
"Send changes only": "Sadece Değişiklikleri Gönder",
"Receive changes only": "Sadece Değişiklikleri Al",
"Disabled": "Devre Dışı",
"Checksum Method": "Kontrol Toplamı Yöntemi",
"File Content (recommended)": "Dosya İçeriği (önerilen)",
"File Name": "Dosya Adı",
"Device Name": "Cihaz Adı",
"Sync Tolerance": "Senkronizasyon Toleransı",
"Precision: {{precision}} decimal places": "Hassasiyet: {{precision}} ondalık basamak",
"Connect to your KOReader Sync server.": "KOReader Senkronizasyon sunucunuza bağlanın.",
"Server URL": "Sunucu URL'si",
"Username": "Kullanıcı Adı",
"Your Username": "Kullanıcı Adınız",
"Password": "Şifre",
"Connect": "Bağlan",
"KOReader Sync": "KOReader Senkronizasyon",
"Sync Conflict": "Senkronizasyon Çatışması",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" cihazından okuma ilerlemesini senkronize etmek istiyor musunuz?",
"another device": "başka bir cihaz",
"Local Progress": "Yerel İlerleme",
"Remote Progress": "Uzak İlerleme",
"Page {{page}} of {{total}} ({{percentage}}%)": "Sayfa {{page}} / {{total}} ({{percentage}}%)",
"Current position": "Mevcut konum",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Yaklaşık sayfa {{page}} / {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Yaklaşık {{percentage}}%"
}

View file

@ -479,5 +479,38 @@
"Are you sure to delete the local copy of the selected book?": "Ви впевнені, що хочете видалити локальну копію вибраної книги?",
"Remove from Cloud & Device": "Видалити з хмари та пристрою",
"Remove from Cloud Only": "Видалити тільки з хмари",
"Remove from Device Only": "Видалити тільки з пристрою"
"Remove from Device Only": "Видалити тільки з пристрою",
"Error": "Помилка",
"Disconnected": "Відключено",
"KOReader Sync Settings": "Налаштування синхронізації KOReader",
"Connected as {{username}}": "Підключено як {{username}}",
"Disconnect": "Відключити",
"Sync Strategy": "Стратегія синхронізації",
"Ask on conflict": "Запитувати при конфлікті",
"Always use latest": "Завжди використовувати останню версію",
"Send changes only": "Відправити тільки зміни",
"Receive changes only": "Отримати тільки зміни",
"Disabled": "Вимкнено",
"Checksum Method": "Метод контрольної суми",
"File Content (recommended)": "Вміст файлу (рекомендується)",
"File Name": "Ім'я файлу",
"Device Name": "Ім'я пристрою",
"Sync Tolerance": "Допустима похибка синхронізації",
"Precision: {{precision}} decimal places": "Точність: {{precision}} десяткових знаків",
"Connect to your KOReader Sync server.": "Підключіться до свого сервера синхронізації KOReader.",
"Server URL": "URL сервера",
"Username": "Ім'я користувача",
"Your Username": "Ваше ім'я користувача",
"Password": "Пароль",
"Connect": "Підключитися",
"KOReader Sync": "Синхронізація KOReader",
"Sync Conflict": "Конфлікт синхронізації",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання з \"{{deviceName}}\"?",
"another device": "інший пристрій",
"Local Progress": "Локальний прогрес",
"Remote Progress": "Віддалений прогрес",
"Page {{page}} of {{total}} ({{percentage}}%)": "Сторінка {{page}} з {{total}} ({{percentage}}%)",
"Current position": "Поточна позиція",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} з {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Приблизно {{percentage}}%"
}

View file

@ -467,5 +467,38 @@
"Are you sure to delete the local copy of the selected book?": "Bạn có chắc chắn muốn xóa bản sao cục bộ của cuốn sách đã chọn không?",
"Remove from Cloud & Device": "Xóa khỏi Đám mây & Thiết bị",
"Remove from Cloud Only": "Xóa chỉ khỏi Đám mây",
"Remove from Device Only": "Xóa chỉ khỏi Thiết bị"
"Remove from Device Only": "Xóa chỉ khỏi Thiết bị",
"Error": "Lỗi",
"Disconnected": "Mất kết nối",
"KOReader Sync Settings": "Cài đặt đồng bộ KOReader",
"Connected as {{username}}": "Đã kết nối với {{username}}",
"Disconnect": "Ngắt kết nối",
"Sync Strategy": "Chiến lược đồng bộ",
"Ask on conflict": "Hỏi khi có xung đột",
"Always use latest": "Luôn sử dụng phiên bản mới nhất",
"Send changes only": "Chỉ gửi thay đổi",
"Receive changes only": "Chỉ nhận thay đổi",
"Disabled": "Đã tắt",
"Checksum Method": "Phương pháp kiểm tra",
"File Content (recommended)": "Nội dung tệp (được khuyến nghị)",
"File Name": "Tên tệp",
"Device Name": "Tên thiết bị",
"Sync Tolerance": "Độ dung sai đồng bộ",
"Precision: {{precision}} decimal places": "Độ chính xác: {{precision}} chữ số thập phân",
"Connect to your KOReader Sync server.": "Kết nối với máy chủ đồng bộ KOReader của bạn.",
"Server URL": "URL máy chủ",
"Username": "Tên người dùng",
"Your Username": "Tên người dùng của bạn",
"Password": "Mật khẩu",
"Connect": "Kết nối",
"KOReader Sync": "Đồng bộ KOReader",
"Sync Conflict": "Xung đột đồng bộ",
"Sync reading progress from \"{{deviceName}}\"?": "Đồng bộ tiến độ đọc từ \"{{deviceName}}\"?",
"another device": "thiết bị khác",
"Local Progress": "Tiến độ cục bộ",
"Remote Progress": "Tiến độ từ xa",
"Page {{page}} of {{total}} ({{percentage}}%)": "Trang {{page}} của {{total}} ({{percentage}}%)",
"Current position": "Vị trí hiện tại",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Khoảng trang {{page}} của {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Khoảng {{percentage}}%"
}

View file

@ -467,5 +467,38 @@
"Are you sure to delete the local copy of the selected book?": "您确定要删除所选书籍的本地副本吗?",
"Remove from Cloud & Device": "从云端和设备中移除",
"Remove from Cloud Only": "仅从云端移除",
"Remove from Device Only": "仅从设备中移除"
"Remove from Device Only": "仅从设备中移除",
"Error": "错误",
"Disconnected": "已断开连接",
"KOReader Sync Settings": "KOReader 同步设置",
"Connected as {{username}}": "已连接为 {{username}}",
"Disconnect": "断开连接",
"Sync Strategy": "同步策略",
"Ask on conflict": "发生冲突时询问",
"Always use latest": "始终使用最新",
"Send changes only": "仅发送更改",
"Receive changes only": "仅接收更改",
"Disabled": "已禁用",
"Checksum Method": "校验和方法",
"File Content (recommended)": "文件内容(推荐)",
"File Name": "文件名",
"Device Name": "设备名称",
"Sync Tolerance": "同步容忍度",
"Precision: {{precision}} decimal places": "精度:{{precision}} 位小数",
"Connect to your KOReader Sync server.": "连接到您的 KOReader 同步服务器。",
"Server URL": "服务器 URL",
"Username": "用户名",
"Your Username": "您的用户名",
"Password": "密码",
"Connect": "连接",
"KOReader Sync": "KOReader 同步",
"Sync Conflict": "同步冲突",
"Sync reading progress from \"{{deviceName}}\"?": "从 \"{{deviceName}}\" 同步阅读进度?",
"another device": "另一台设备",
"Local Progress": "本地进度",
"Remote Progress": "远程进度",
"Page {{page}} of {{total}} ({{percentage}}%)": "第 {{page}} 页,共 {{total}} 页 ({{percentage}}%)",
"Current position": "当前位置",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "大约第 {{page}} 页,共 {{total}} 页 ({{percentage}}%)",
"Approximately {{percentage}}%": "大约 {{percentage}}%"
}

View file

@ -467,5 +467,38 @@
"Are you sure to delete the local copy of the selected book?": "您確定要刪除所選書籍的本地副本嗎?",
"Remove from Cloud & Device": "從雲端和設備中移除",
"Remove from Cloud Only": "僅從雲端移除",
"Remove from Device Only": "僅從設備中移除"
"Remove from Device Only": "僅從設備中移除",
"Error": "錯誤",
"Disconnected": "已斷開連接",
"KOReader Sync Settings": "KOReader 同步設置",
"Connected as {{username}}": "已連接為 {{username}}",
"Disconnect": "斷開連接",
"Sync Strategy": "同步策略",
"Ask on conflict": "發生衝突時詢問",
"Always use latest": "始終使用最新",
"Send changes only": "僅發送更改",
"Receive changes only": "僅接收更改",
"Disabled": "已禁用",
"Checksum Method": "校驗和方法",
"File Content (recommended)": "文件內容(推薦)",
"File Name": "文件名",
"Device Name": "設備名稱",
"Sync Tolerance": "同步容忍度",
"Precision: {{precision}} decimal places": "精度:{{precision}} 位小數",
"Connect to your KOReader Sync server.": "連接到您的 KOReader 同步伺服器。",
"Server URL": "伺服器 URL",
"Username": "用戶名",
"Your Username": "您的用戶名",
"Password": "密碼",
"Connect": "連接",
"KOReader Sync": "KOReader 同步",
"Sync Conflict": "同步衝突",
"Sync reading progress from \"{{deviceName}}\"?": "從 \"{{deviceName}}\" 同步閱讀進度?",
"another device": "另一個設備",
"Local Progress": "本地進度",
"Remote Progress": "遠程進度",
"Page {{page}} of {{total}} ({{percentage}}%)": "第 {{page}} 頁,共 {{total}} 頁 ({{percentage}}%)",
"Current position": "當前位置",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "大約第 {{page}} 頁,共 {{total}} 頁 ({{percentage}}%)",
"Approximately {{percentage}}%": "大約 {{percentage}}%"
}

View file

@ -0,0 +1,368 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { md5 } from 'js-md5';
import clsx from 'clsx';
import { type as osType } from '@tauri-apps/plugin-os';
import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useEnv } from '@/context/EnvContext';
import { eventDispatcher } from '@/utils/event';
import { KOSyncClient } from '@/services/sync/KOSyncClient';
import { KoreaderSyncChecksumMethod, KoreaderSyncStrategy } from '@/types/settings';
import { v4 as uuidv4 } from 'uuid';
import { debounce } from '@/utils/debounce';
import { getOSPlatform } from '@/utils/misc';
type Option = {
value: string;
label: string;
};
type SelectProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: Option[];
disabled?: boolean;
className?: string;
};
const StyledSelect: React.FC<SelectProps> = ({
value,
onChange,
options,
className,
disabled = false,
}) => {
return (
<select
value={value}
onChange={onChange}
className={clsx(
'select select-bordered h-12 w-full text-sm focus:outline-none focus:ring-0',
className,
)}
disabled={disabled}
>
{options.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
);
};
export const setKOSyncSettingsWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('kosync_settings_window');
if (dialog) {
const event = new CustomEvent('setKOSyncSettingsVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const KOSyncSettingsWindow: React.FC = () => {
const _ = useTranslation();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { envConfig, appService } = useEnv();
const [isOpen, setIsOpen] = useState(false);
const [url, setUrl] = useState(settings.koreaderSyncServerUrl || '');
const [username, setUsername] = useState(settings.koreaderSyncUsername || '');
const [password, setPassword] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('');
const [deviceName, setDeviceName] = useState('');
const [osName, setOsName] = useState('');
const [toleranceSliderValue, setToleranceSliderValue] = useState(() => {
const tolerance = settings.koreaderSyncPercentageTolerance;
return tolerance && tolerance > 0 ? Math.round(-Math.log10(tolerance)) : 4;
});
// Get the OS name once
useEffect(() => {
const getOsName = async () => {
let name = '';
if (appService?.appPlatform === 'tauri') {
name = await osType();
} else {
const platform = getOSPlatform();
if (platform !== 'unknown') {
name = platform;
}
}
setOsName(name ? name.charAt(0).toUpperCase() + name.slice(1) : '');
};
getOsName();
}, [appService]);
useEffect(() => {
const defaultName = osName ? `Readest (${osName})` : 'Readest';
setDeviceName(settings.koreaderSyncDeviceName || defaultName);
}, [settings.koreaderSyncDeviceName, osName]);
const isConfigured = useMemo(
() => !!settings.koreaderSyncUserkey,
[settings.koreaderSyncUserkey],
);
const debouncedSaveDeviceName = useCallback(
debounce((newDeviceName: string) => {
const newSettings = { ...settings, koreaderSyncDeviceName: newDeviceName };
setSettings(newSettings);
saveSettings(envConfig, newSettings);
}, 500),
[settings, setSettings, saveSettings, envConfig],
);
const handleDeviceNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value;
setDeviceName(newName);
debouncedSaveDeviceName(newName);
};
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
setUrl(settings.koreaderSyncServerUrl || '');
setUsername(settings.koreaderSyncUsername || '');
setPassword('');
setConnectionStatus('');
// Sync the slider with the current settings when opening
const tolerance = settings.koreaderSyncPercentageTolerance;
setToleranceSliderValue(
tolerance && tolerance > 0 ? Math.round(-Math.log10(tolerance)) : 4,
);
}
};
const el = document.getElementById('kosync_settings_window');
el?.addEventListener('setKOSyncSettingsVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setKOSyncSettingsVisibility', handleCustomEvent as EventListener);
};
}, [
settings.koreaderSyncServerUrl,
settings.koreaderSyncUsername,
settings.koreaderSyncPercentageTolerance,
]);
const handleConnect = async () => {
setIsConnecting(true);
let deviceId = settings.koreaderSyncDeviceId;
if (!deviceId) {
deviceId = uuidv4().replace(/-/g, '').toUpperCase();
}
const client = new KOSyncClient(
url,
username,
md5(password),
settings.koreaderSyncChecksumMethod,
deviceId,
deviceName,
);
const result = await client.connect(username, password);
if (result.success) {
const newSettings = {
...settings,
koreaderSyncServerUrl: url,
koreaderSyncUsername: username,
koreaderSyncUserkey: md5(password),
koreaderSyncDeviceId: deviceId,
koreaderSyncDeviceName: deviceName,
koreaderSyncStrategy:
settings.koreaderSyncStrategy === 'disabled' ? 'prompt' : settings.koreaderSyncStrategy,
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
eventDispatcher.dispatch('toast', {
message: _(result.message || 'Successfully connected!'),
type: 'success',
});
} else {
setConnectionStatus('');
eventDispatcher.dispatch('toast', {
message: `${_('Error')}: ${_(result.message || 'Connection error')}`,
type: 'error',
});
}
setIsConnecting(false);
setPassword('');
};
const handleDisconnect = async () => {
const newSettings = {
...settings,
koreaderSyncStrategy: 'disabled' as KoreaderSyncStrategy,
koreaderSyncUserkey: '',
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
setUsername('');
eventDispatcher.dispatch('toast', { message: _('Disconnected'), type: 'info' });
};
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newStrategy = e.target.value as KoreaderSyncStrategy;
const newSettings = { ...settings, koreaderSyncStrategy: newStrategy };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const handleChecksumMethodChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newMethod = e.target.value as KoreaderSyncChecksumMethod;
const newSettings = { ...settings, koreaderSyncChecksumMethod: newMethod };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const handleToleranceChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const sliderValue = parseInt(e.target.value, 10);
setToleranceSliderValue(sliderValue);
// Calculate the actual tolerance from the slider value (e.g., 4 -> 0.0001)
const newTolerance = Math.pow(10, -sliderValue);
const newSettings = { ...settings, koreaderSyncPercentageTolerance: newTolerance };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
return (
<Dialog
id='kosync_settings_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('KOReader Sync Settings')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
<div className='flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
<>
<div className='text-center'>
<p className='text-base-content/80 py-2 text-sm'>
{_('Connected as {{username}}', { username: settings.koreaderSyncUsername })}
</p>
<button className='btn btn-warning h-12 min-h-12 w-full' onClick={handleDisconnect}>
{_('Disconnect')}
</button>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Sync Strategy')}</span>
</label>
<StyledSelect
value={settings.koreaderSyncStrategy}
onChange={handleStrategyChange}
options={[
{ value: 'prompt', label: _('Ask on conflict') },
{ value: 'silent', label: _('Always use latest') },
{ value: 'send', label: _('Send changes only') },
{ value: 'receive', label: _('Receive changes only') },
{ value: 'disable', label: _('Disabled') },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Checksum Method')}</span>
</label>
<StyledSelect
value={settings.koreaderSyncChecksumMethod}
onChange={handleChecksumMethodChange}
options={[
{ value: 'binary', label: _('File Content (recommended)') },
{ value: 'filename', label: _('File Name') },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Device Name')}</span>
</label>
<input
type='text'
placeholder={osName ? `Readest (${osName})` : 'Readest'}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={deviceName}
onChange={handleDeviceNameChange}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Sync Tolerance')}</span>
</label>
<input
type='range'
min='0'
max='15'
value={toleranceSliderValue}
onChange={handleToleranceChange}
className='range range-primary'
/>
<div className='text-base-content/70 mt-2 text-center text-xs'>
{_('Precision: {{precision}} decimal places', { precision: toleranceSliderValue })}
</div>
</div>
</>
) : (
<>
<p className='text-base-content/70 pt-2 text-center text-sm'>
{_('Connect to your KOReader Sync server.')}
</p>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Server URL')}</span>
</label>
<input
type='text'
placeholder='https://koreader.sync.server'
className='input input-bordered h-12 w-full'
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Username')}</span>
</label>
<input
type='text'
placeholder={_('Your Username')}
className='input input-bordered h-12 w-full'
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Password')}</span>
</label>
<input
type='password'
placeholder={_('Your Password')}
className='input input-bordered h-12 w-full'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
disabled={isConnecting || !url || !username || !password}
>
{isConnecting ? <span className='loading loading-spinner'></span> : _('Connect')}
</button>
{connectionStatus && (
<div className='text-error h-4 text-center text-sm'>{connectionStatus}</div>
)}
</>
)}
</div>
</Dialog>
);
};

View file

@ -8,6 +8,7 @@ import { TbSunMoon } from 'react-icons/tb';
import { BiMoon, BiSun } from 'react-icons/bi';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { setKOSyncSettingsWindowVisible } from './KOSyncSettings';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
@ -158,6 +159,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsTelemetryEnabled(settings.telemetryEnabled);
};
const showKoSyncSettingsWindow = () => {
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handleUpgrade = () => {
navigateToProfile(router);
setIsDropdownOpen?.(false);
@ -268,6 +274,8 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={cycleThemeMode}
/>
<hr className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
<hr className='border-base-200 my-1' />
{user && userPlan === 'free' && !appService?.isIOSApp && (
<MenuItem label={_('Upgrade to Readest Premium')} onClick={handleUpgrade} />
)}

View file

@ -48,6 +48,7 @@ import {
} from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { KOSyncSettingsWindow } from './components/KOSyncSettings';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { BookMetadata } from '@/libs/document';
import { BookDetailModal } from '@/components/metadata';
@ -753,6 +754,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
/>
)}
<AboutWindow />
<KOSyncSettingsWindow />
<UpdaterWindow />
<Toast />
</div>

View file

@ -0,0 +1,49 @@
import React from 'react';
import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { SyncDetails } from '../hooks/useKOSync';
interface ConfirmSyncDialogProps {
details: SyncDetails | null;
onConfirmLocal: () => void;
onConfirmRemote: () => void;
onClose: () => void;
}
const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
details,
onConfirmLocal,
onConfirmRemote,
onClose,
}) => {
const _ = useTranslation();
if (!details) return null;
return (
<Dialog isOpen={true} onClose={onClose} title={_('Sync Conflict')}>
<p className='py-4 text-center'>
{_('Sync reading progress from "{{deviceName}}"?', {
deviceName: details.remote.device || _('another device'),
})}
</p>
<div className='mt-4 space-y-4'>
<button className='btn h-auto w-full flex-col items-start py-2' onClick={onConfirmLocal}>
<span>{_('Local Progress')}</span>
<span className='text-xs font-normal normal-case text-gray-500'>
{details.local.preview}
</span>
</button>
<button
className='btn btn-primary h-auto w-full flex-col items-start py-2'
onClick={onConfirmRemote}
>
<span>{_('Remote Progress')}</span>
<span className='text-xs font-normal normal-case'>{details.remote.preview}</span>
</button>
</div>
</Dialog>
);
};
export default ConfirmSyncDialog;

View file

@ -13,6 +13,7 @@ import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useKOSync } from '../hooks/useKOSync';
import {
applyFixedlayoutStyles,
applyImageStyle,
@ -42,6 +43,7 @@ import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
import ConfirmSyncDialog from './ConfirmSyncDialog';
declare global {
interface Window {
@ -77,6 +79,12 @@ const FoliateViewer: React.FC<{
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
const {
syncState,
conflictDetails,
resolveConflictWithLocal,
resolveConflictWithRemote,
} = useKOSync(bookKey);
useTextTranslation(bookKey, viewRef.current);
const progressRelocateHandler = (event: Event) => {
@ -349,12 +357,22 @@ const FoliateViewer: React.FC<{
]);
return (
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
<>
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
{syncState === 'conflict' && conflictDetails && (
<ConfirmSyncDialog
details={conflictDetails}
onConfirmLocal={resolveConflictWithLocal}
onConfirmRemote={resolveConflictWithRemote}
onClose={resolveConflictWithLocal}
/>
)}
</>
);
};

View file

@ -1,8 +1,7 @@
'use client';
import clsx from 'clsx';
import * as React from 'react';
import { useState, useRef, useEffect } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Book } from '@/types/book';
@ -75,6 +74,10 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
return true;
};
eventDispatcher.onSync('show-book-details', handleShowBookDetails);
return () => {
eventDispatcher.offSync('show-book-details', handleShowBookDetails);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -106,8 +109,9 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { book } = getBookData(bookKey) || {};
const { isPrimary } = getViewState(bookKey) || {};
if (isPrimary && book && config) {
eventDispatcher.dispatch('sync-book-progress', { bookKey });
const settings = useSettingsStore.getState().settings;
eventDispatcher.dispatch('sync-book-progress', { bookKey });
eventDispatcher.dispatch('flush-koreader-sync', { bookKey });
await saveConfig(envConfig, bookKey, config, settings);
}
};

View file

@ -0,0 +1,482 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { md5 } from 'js-md5';
import { type as osType } from '@tauri-apps/plugin-os';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { KOSyncClient, KoSyncProgress } from '@/services/sync/KOSyncClient';
import { Book, BookFormat } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { getCFIFromXPointer, XCFI } from '@/utils/xcfi';
const PAGINATED_FORMATS: Set<BookFormat> = new Set(['PDF', 'CBZ']);
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
export interface SyncDetails {
book: Book;
bookDoc: BookDoc;
local: {
cfi?: string;
preview: string;
};
remote: KoSyncProgress & {
preview: string;
percentage?: number;
};
}
export const useKOSync = (bookKey: string) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { getProgress, getView } = useReaderStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const progress = getProgress(bookKey);
const [syncState, setSyncState] = useState<SyncState>('idle');
const [conflictDetails, setConflictDetails] = useState<SyncDetails | null>(null);
const [errorMessage] = useState<string | null>(null);
const syncCompletedForKey = useRef<string | null>(null);
const lastPushedCfiRef = useRef<string | null>(null);
const bookData = getBookData(bookKey);
const book = bookData?.book;
const bookDoc = bookData?.bookDoc;
useEffect(() => {
lastPushedCfiRef.current = null;
syncCompletedForKey.current = null;
setSyncState('idle');
}, [bookKey]);
const mapProgressToServerFormat = useCallback(() => {
const currentProgress = getProgress(bookKey);
const currentBook = getBookData(bookKey)?.book;
if (!currentProgress || !currentBook) return null;
let progressStr: string;
let percentage: number;
if (PAGINATED_FORMATS.has(currentBook.format)) {
const page = (currentProgress.section?.current ?? 0) + 1;
const totalPages = currentProgress.section?.total ?? 0;
progressStr = page.toString();
percentage = totalPages > 0 ? page / totalPages : 0;
} else {
progressStr = currentProgress.location;
const view = getView(bookKey);
if (view && progressStr) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index: spineIndex } = content;
const converter = new XCFI(doc, spineIndex || 0);
const xpointerResult = converter.cfiToXPointer(progressStr);
progressStr = xpointerResult.xpointer;
}
} catch (error) {
console.error(
'Failed to convert CFI to XPointer. Progress will be sent as percentage only.',
error,
);
}
}
const page = currentProgress.pageinfo?.current ?? 0;
const totalPages = currentProgress.pageinfo?.total ?? 0;
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
}
return { progressStr, percentage };
}, [bookKey, getProgress, getBookData, getView]);
const pushProgress = useMemo(
() =>
debounce(async () => {
const { settings: currentSettings } = useSettingsStore.getState();
const currentBook = getBookData(bookKey)?.book;
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = currentSettings;
if (
!koreaderSyncUsername ||
!koreaderSyncUserkey ||
['receive', 'disable'].includes(koreaderSyncStrategy) ||
!currentBook
)
return;
const getDocumentDigest = (bookToDigest: Book): string => {
if (currentSettings.koreaderSyncChecksumMethod === 'filename') {
const filename = bookToDigest.sourceTitle || bookToDigest.title;
const normalizedPath = filename.replace(/\\/g, '/');
return md5(
normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath,
);
}
return bookToDigest.hash;
};
const getDeviceName = async () => {
if (currentSettings.koreaderSyncDeviceName) return currentSettings.koreaderSyncDeviceName;
if (appService?.appPlatform === 'tauri') {
const name = await osType();
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
}
return 'Readest';
};
const digest = getDocumentDigest(currentBook);
const progressData = mapProgressToServerFormat();
if (!digest || !progressData) return;
if (progressData.progressStr === lastPushedCfiRef.current) return;
const deviceName = await getDeviceName();
const client = new KOSyncClient(
currentSettings.koreaderSyncServerUrl,
currentSettings.koreaderSyncUsername,
currentSettings.koreaderSyncUserkey,
currentSettings.koreaderSyncChecksumMethod,
currentSettings.koreaderSyncDeviceId,
deviceName,
);
await client.updateProgress(currentBook, progressData.progressStr, progressData.percentage);
lastPushedCfiRef.current = progressData.progressStr;
}, 5000),
[bookKey, appService, getBookData, mapProgressToServerFormat],
);
useEffect(() => {
const handleFlush = (event: CustomEvent) => {
const { bookKey: syncBookKey } = event.detail;
if (syncBookKey === bookKey) {
pushProgress.flush();
}
};
eventDispatcher.on('flush-koreader-sync', handleFlush);
return () => {
eventDispatcher.off('flush-koreader-sync', handleFlush);
pushProgress.flush();
};
}, [bookKey, pushProgress]);
useEffect(() => {
const performInitialSync = async () => {
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = settings;
if (
!book ||
!bookDoc ||
!progress ||
!koreaderSyncUsername ||
!koreaderSyncUserkey ||
koreaderSyncStrategy === 'disabled'
)
return;
if (koreaderSyncStrategy === 'send') {
syncCompletedForKey.current = bookKey;
setSyncState('synced');
return;
}
setSyncState('checking');
const getDeviceName = async () => {
if (settings.koreaderSyncDeviceName) return settings.koreaderSyncDeviceName;
if (appService?.appPlatform === 'tauri') {
const name = await osType();
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
}
return 'Readest';
};
const deviceName = await getDeviceName();
const client = new KOSyncClient(
settings.koreaderSyncServerUrl,
settings.koreaderSyncUsername,
settings.koreaderSyncUserkey,
settings.koreaderSyncChecksumMethod,
settings.koreaderSyncDeviceId,
deviceName,
);
const remote = await client.getProgress(book);
lastPushedCfiRef.current = progress.location;
if (!remote?.progress || !remote?.timestamp) {
syncCompletedForKey.current = bookKey;
setSyncState('synced');
if (settings.koreaderSyncStrategy !== 'receive') {
pushProgress();
pushProgress.flush();
}
return;
}
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
const remoteIsNewer = remote.timestamp * 1000 > localTimestamp;
const localIdentifier = PAGINATED_FORMATS.has(book.format)
? progress.section?.current.toString()
: progress.location;
const isLocalCFI = localIdentifier?.startsWith('epubcfi');
const remoteIdentifier = PAGINATED_FORMATS.has(book.format)
? (parseInt(remote.progress, 10) - 1).toString()
: remote.progress.startsWith('epubcfi')
? remote.progress
: null;
const isRemoteCFI = remoteIdentifier?.startsWith('epubcfi');
let isProgressIdentical = false;
if (isLocalCFI && isRemoteCFI) {
isProgressIdentical = localIdentifier === remoteIdentifier;
}
if (!isProgressIdentical) {
const localPercentage = mapProgressToServerFormat()?.percentage ?? 0;
const remotePercentage = remote.percentage;
if (remotePercentage !== undefined && remotePercentage !== null) {
const tolerance = settings.koreaderSyncPercentageTolerance;
const percentageDifference = Math.abs(localPercentage - remotePercentage);
isProgressIdentical = percentageDifference < tolerance;
}
}
if (isProgressIdentical) {
lastPushedCfiRef.current = localIdentifier;
syncCompletedForKey.current = bookKey;
setSyncState('synced');
return;
}
if (
settings.koreaderSyncStrategy === 'receive' ||
(settings.koreaderSyncStrategy === 'silent' && remoteIsNewer)
) {
const applyRemoteProgress = async () => {
const view = getView(bookKey);
if (view && remote.progress) {
if (PAGINATED_FORMATS.has(book.format)) {
const pageToGo = parseInt(remote.progress, 10);
if (!isNaN(pageToGo)) view.select(pageToGo - 1);
} else {
const isXPointer = remote.progress.startsWith('/body');
if (isXPointer) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index } = content;
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
view.goTo(cfi);
eventDispatcher.dispatch('toast', {
message: _('Reading Progress Synced'),
type: 'info',
});
}
} catch (error) {
console.error(
'Failed to convert XPointer to CFI, falling back to percentage.',
error,
);
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
} else {
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
}
eventDispatcher.dispatch('toast', {
message: _('Reading Progress Synced'),
type: 'info',
});
}
};
applyRemoteProgress();
syncCompletedForKey.current = bookKey;
setSyncState('synced');
} else if (settings.koreaderSyncStrategy === 'prompt') {
let localPreview = '';
let remotePreview = '';
const remotePercentage = remote.percentage || 0;
if (PAGINATED_FORMATS.has(book.format)) {
const localPageInfo = progress.section;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = localPageInfo
? _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: localPageInfo.current + 1,
total: localPageInfo.total,
percentage: localPercentage,
})
: _('Current position');
const remotePage = parseInt(remote.progress, 10);
if (!isNaN(remotePage) && remotePercentage > 0) {
const localTotalPages = localPageInfo?.total ?? 0;
const remoteTotalPages = Math.round(remotePage / remotePercentage);
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
if (pagesMatch) {
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
} else {
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
}
} else {
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
} else {
const localPageInfo = progress.pageinfo;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = `${progress.sectionLabel} (${localPercentage}%)`;
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
setConflictDetails({
book,
bookDoc,
local: { cfi: progress.location, preview: localPreview },
remote: { ...remote, preview: remotePreview, percentage: remote.percentage },
});
setSyncState('conflict');
} else {
syncCompletedForKey.current = bookKey;
setSyncState('synced');
}
};
if (bookKey && book && progress && syncCompletedForKey.current !== bookKey) {
syncCompletedForKey.current = bookKey;
performInitialSync();
}
}, [
bookKey,
book,
bookDoc,
progress,
settings,
appService,
getBookData,
getProgress,
getView,
mapProgressToServerFormat,
pushProgress,
_,
bookData?.config?.updatedAt,
]);
useEffect(() => {
if (syncState === 'synced' && progress) {
if (
settings.koreaderSyncStrategy !== 'receive' &&
settings.koreaderSyncStrategy !== 'disabled'
) {
pushProgress();
}
}
}, [progress, syncState, settings.koreaderSyncStrategy, pushProgress]);
useEffect(() => {
return () => {
pushProgress.flush();
};
}, [pushProgress]);
const resolveConflictWithLocal = () => {
pushProgress();
pushProgress.flush();
setSyncState('synced');
setConflictDetails(null);
};
const resolveConflictWithRemote = async () => {
const view = getView(bookKey);
const remote = conflictDetails?.remote;
const currentBook = conflictDetails?.book;
const bookDoc = conflictDetails?.bookDoc;
if (view && remote?.progress && currentBook) {
if (PAGINATED_FORMATS.has(currentBook.format)) {
const localTotalPages = getProgress(bookKey)?.section?.total ?? 0;
const remotePage = parseInt(remote.progress, 10);
const remotePercentage = remote.percentage || 0;
const remoteTotalPages =
remotePercentage > 0 ? Math.round(remotePage / remotePercentage) : 0;
if (!isNaN(remotePage) && Math.abs(localTotalPages - remoteTotalPages) <= 1) {
console.log('Going to remote page:', remotePage);
view.select(remotePage - 1);
} else if (remote.percentage !== undefined && remote.percentage !== null) {
console.log('Going to remote percentage:', remote.percentage);
view.goToFraction(remote.percentage);
}
} else {
const isXPointer = remote.progress.startsWith('/body');
const isCFI = remote.progress.startsWith('epubcfi');
if (isXPointer) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index } = content;
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
view.goTo(cfi);
}
} catch (error) {
console.error('Failed to convert XPointer to CFI, falling back to percentage.', error);
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
} else if (isCFI) {
view.goTo(remote.progress);
} else if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
eventDispatcher.dispatch('toast', { message: _('Reading Progress Synced'), type: 'info' });
}
setSyncState('synced');
setConflictDetails(null);
};
return {
syncState,
conflictDetails,
errorMessage,
resolveConflictWithLocal,
resolveConflictWithRemote,
pushProgress,
};
};

View file

@ -57,6 +57,8 @@ export interface SectionItem {
size: number;
linear: string;
location?: Location;
createDocument: () => Promise<Document>;
}
export type BookMetadata = {

View file

@ -0,0 +1,54 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { KoSyncProxyPayload } from '@/types/kosync';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
const {
serverUrl,
endpoint,
method,
headers: clientHeaders,
body: clientBody,
} = req.body as KoSyncProxyPayload;
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
if (!serverUrl || !endpoint) {
return res.status(400).json({ error: 'serverUrl and endpoint are required' });
}
const targetUrl = `${serverUrl.replace(/\/$/, '')}${endpoint}`;
try {
const response = await fetch(targetUrl, {
method: method,
headers: {
...clientHeaders,
Accept: 'application/vnd.koreader.v1+json',
'Content-Type': 'application/json',
},
body: clientBody ? JSON.stringify(clientBody) : null,
});
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Invalid sync server response: Unexpected Content-Type.');
}
const data = await response.text();
res.status(response.status);
try {
res.json(JSON.parse(data));
} catch {
res.send(data);
}
} catch (error) {
console.error('[KOSYNC PROXY] Error:', error);
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
res.status(500).json({ error: 'Proxy request failed', details: errorMessage });
}
}

View file

@ -1,6 +1,6 @@
import { AppPlatform, AppService, OsPlatform } from '@/types/system';
import { v4 as uuidv4 } from 'uuid';
import { SystemSettings } from '@/types/settings';
import { AppPlatform, AppService, OsPlatform } from '@/types/system';
import { FileSystem, BaseDir, DeleteAction } from '@/types/system';
import { Book, BookConfig, BookContent, BookFormat, ViewSettings } from '@/types/book';
import {
@ -116,11 +116,17 @@ export abstract class BaseAppService implements AppService {
...this.getDefaultViewSettings(),
...settings.globalViewSettings,
};
if (!settings.koreaderSyncDeviceId) {
settings.koreaderSyncDeviceId = uuidv4();
await this.fs.writeFile(fp, base, JSON.stringify(settings));
}
} catch {
settings = {
...DEFAULT_SYSTEM_SETTINGS,
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await this.fs.getPrefix('Books'),
koreaderSyncDeviceId: uuidv4(),
globalReadSettings: {
...DEFAULT_READSETTINGS,
...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),

View file

@ -53,6 +53,15 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
librarySortAscending: false,
libraryCoverFit: 'crop',
koreaderSyncServerUrl: 'https://sync.koreader.rocks/', // https://kosync.ak-team.com:3042/
koreaderSyncUsername: '',
koreaderSyncUserkey: '',
koreaderSyncDeviceId: '',
koreaderSyncDeviceName: '',
koreaderSyncChecksumMethod: 'binary',
koreaderSyncStrategy: 'prompt',
koreaderSyncPercentageTolerance: 0.00001,
lastSyncedAtBooks: 0,
lastSyncedAtConfigs: 0,
lastSyncedAtNotes: 0,

View file

@ -0,0 +1,218 @@
import { md5 } from 'js-md5';
import { Book } from '@/types/book';
import { KoreaderSyncChecksumMethod } from '@/types/settings';
import { getAPIBaseUrl } from '../environment';
import { KoSyncProxyPayload } from '@/types/kosync';
/**
* Interface for KOSync progress response from the server
*/
export interface KoSyncProgress {
document?: string;
progress?: string;
percentage?: number;
timestamp?: number;
device?: string;
device_id?: string;
}
export class KOSyncClient {
private serverUrl: string;
private username: string;
private userkey: string;
private checksumMethod: KoreaderSyncChecksumMethod;
private deviceId: string;
private deviceName: string;
constructor(
serverUrl: string,
username: string,
userkey: string,
checksumMethod: KoreaderSyncChecksumMethod,
deviceId: string,
deviceName: string,
) {
this.serverUrl = serverUrl.replace(/\/$/, '');
this.username = username;
this.userkey = userkey;
this.checksumMethod = checksumMethod;
this.deviceId = deviceId;
this.deviceName = deviceName;
}
private async request(
endpoint: string,
options: {
method?: 'GET' | 'POST' | 'PUT';
body?: BodyInit | null;
headers?: HeadersInit;
useAuth?: boolean;
} = {},
): Promise<Response> {
const { method = 'GET', body, headers: additionalHeaders, useAuth = true } = options;
const headers = new Headers(additionalHeaders || {});
if (useAuth) {
headers.set('X-Auth-User', this.username);
headers.set('X-Auth-Key', this.userkey);
}
const proxyUrl = `${getAPIBaseUrl()}/kosync`;
const proxyBody: KoSyncProxyPayload = {
serverUrl: this.serverUrl,
endpoint,
method,
headers: Object.fromEntries(headers.entries()),
body: body ? JSON.parse(body as string) : undefined,
};
return fetch(proxyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(proxyBody),
});
}
/**
* Connects to the KOSync server with authentication
* @param username - The username for authentication
* @param password - The password for authentication
* @returns Promise with success status and optional message
*/
async connect(
username: string,
password: string,
): Promise<{ success: boolean; message?: string }> {
const userkey = md5(password);
try {
const authResponse = await this.request('/users/auth', {
method: 'GET',
headers: {
'X-Auth-User': username,
'X-Auth-Key': userkey,
},
});
if (authResponse.ok) {
return { success: true, message: 'Login successful.' };
}
if (authResponse.status === 401) {
const registerResponse = await this.request('/users/create', {
method: 'POST',
useAuth: false,
body: JSON.stringify({ username, password: userkey }),
});
if (registerResponse.ok) {
return { success: true, message: 'Registration successful.' };
}
const regError = await registerResponse.json().catch(() => ({}));
if (registerResponse.status === 402) {
return { success: false, message: 'Invalid credentials.' };
}
return { success: false, message: regError.message || 'Registration failed.' };
}
const errorBody = await authResponse.json().catch(() => ({}));
return {
success: false,
message: errorBody.message || `Authorization failed with status: ${authResponse.status}`,
};
} catch (e) {
console.error('KOSync connection failed', e);
return { success: false, message: (e as Error).message || 'Connection error.' };
}
}
/**
* Retrieves the reading progress for a specific book from the server
* @param book - The book to get progress for
* @returns Promise with the progress data or null if not found
*/
async getProgress(book: Book): Promise<KoSyncProgress | null> {
if (!this.userkey) return null;
const documentHash = this.getDocumentDigest(book);
if (!documentHash) return null;
try {
const response = await this.request(`/syncs/progress/${documentHash}`);
if (!response.ok) {
console.error(
`KOSync: Failed to get progress for ${book.title}. Status: ${response.status}`,
);
return null;
}
const data = await response.json();
return data.document ? data : null;
} catch (e) {
console.error('KOSync getProgress failed', e);
return null;
}
}
/**
* Updates the reading progress for a specific book on the server
* @param book - The book to update progress for
* @param progress - The current reading progress position
* @param percentage - The reading completion percentage
* @returns Promise with boolean indicating success
*/
async updateProgress(book: Book, progress: string, percentage: number): Promise<boolean> {
if (!this.userkey) return false;
const documentHash = this.getDocumentDigest(book);
if (!documentHash) return false;
const payload = {
document: documentHash,
progress: progress.toString(),
percentage,
device: this.deviceName,
device_id: this.deviceId,
};
try {
const response = await this.request('/syncs/progress', {
method: 'PUT',
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error(
`KOSync: Failed to update progress for ${book.title}. Status: ${response.status}`,
);
return false;
}
return true;
} catch (e) {
console.error('KOSync updateProgress failed', e);
return false;
}
}
private getDocumentDigest(book: Book): string | undefined {
if (this.checksumMethod === 'filename') {
const filename = this.getBaseFilename(book.sourceTitle || book.title);
return md5(filename);
}
return book.hash;
}
private getBaseFilename(fullPath: string): string {
// Normalize path by replacing backslashes with forward slashes for cross-platform consistency
const normalizedPath = fullPath.replace(/\\/g, '/');
// Get the last part of the path and remove the extension
const baseName =
normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath;
return baseName;
}
}

View file

@ -0,0 +1,11 @@
/**
* Defines the contract for the request body that the client will send
* to our API endpoint that acts as a proxy.
*/
export interface KoSyncProxyPayload {
serverUrl: string;
endpoint: string;
method: 'GET' | 'POST' | 'PUT';
headers: Record<string, string>;
body?: unknown;
}

View file

@ -6,6 +6,9 @@ export type LibraryViewModeType = 'grid' | 'list';
export type LibrarySortByType = 'title' | 'author' | 'updated' | 'created' | 'size' | 'format';
export type LibraryCoverFitType = 'crop' | 'fit';
export type KoreaderSyncChecksumMethod = 'binary' | 'filename';
export type KoreaderSyncStrategy = 'prompt' | 'silent' | 'send' | 'receive' | 'disabled';
export interface ReadSettings {
sideBarWidth: string;
isSideBarPinned: boolean;
@ -40,6 +43,15 @@ export interface SystemSettings {
librarySortAscending: boolean;
libraryCoverFit: LibraryCoverFitType;
koreaderSyncServerUrl: string;
koreaderSyncUsername: string;
koreaderSyncUserkey: string;
koreaderSyncDeviceId: string;
koreaderSyncDeviceName: string;
koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod;
koreaderSyncStrategy: KoreaderSyncStrategy;
koreaderSyncPercentageTolerance: number;
lastSyncedAtBooks: number;
lastSyncedAtConfigs: number;
lastSyncedAtNotes: number;

View file

@ -5,22 +5,24 @@ interface DebounceOptions {
/**
* Debounces a function by waiting `delay` ms after the last call before executing it.
* If `emitLast` is false, it cancels the call instead of delaying it.
*
* @returns A debounced function with additional `flush` and `cancel` methods.
*/
export const debounce = <T extends (...args: Parameters<T>) => void | Promise<void>>(
func: T,
delay: number,
options: DebounceOptions = { emitLast: true },
): ((...args: Parameters<T>) => void) => {
): ((...args: Parameters<T>) => void) & { flush: () => void; cancel: () => void } => {
let timeout: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
return (...args: Parameters<T>): void => {
const debounced = (...args: Parameters<T>): void => {
lastArgs = args;
if (timeout) {
clearTimeout(timeout);
}
if (options.emitLast) {
lastArgs = args;
timeout = setTimeout(() => {
if (lastArgs) {
func(...(lastArgs as Parameters<T>));
@ -35,4 +37,31 @@ export const debounce = <T extends (...args: Parameters<T>) => void | Promise<vo
}, delay);
}
};
/**
* Immediately executes the last pending debounced function call.
*/
debounced.flush = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
if (lastArgs) {
func(...(lastArgs as Parameters<T>));
lastArgs = null;
}
}
};
/**
* Cancels the pending debounced function call.
*/
debounced.cancel = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
lastArgs = null;
}
};
return debounced;
};

View file

@ -3,6 +3,7 @@
* Converts between Readest (foliate-js) CFI format and KOReader CREngine XPointer format
*/
import { BookDoc } from '@/libs/document';
import { parse, fake, collapse, fromRange, toRange, toElement } from 'foliate-js/epubcfi.js';
type XPointer = {
@ -498,3 +499,23 @@ export class XCFI {
return !inlineElements.has(tagName);
}
}
export const getCFIFromXPointer = async (
xpointer: string,
doc: Document,
index: number,
bookDoc?: BookDoc,
) => {
const xSpineIndex = XCFI.extractSpineIndex(xpointer);
let converter: XCFI;
if (index === xSpineIndex) {
converter = new XCFI(doc, index || 0);
} else {
const doc = await bookDoc?.sections?.[xSpineIndex]?.createDocument();
if (!doc) throw new Error('Failed to load document for XPointer conversion.');
converter = new XCFI(doc, xSpineIndex || 0);
}
const cfi = converter.xPointerToCFI(xpointer);
return cfi;
};

View file

@ -206,6 +206,9 @@ importers:
tinycolor2:
specifier: ^1.6.0
version: 1.6.0
uuid:
specifier: ^11.1.0
version: 11.1.0
zod:
specifier: ^4.0.8
version: 4.0.10
@ -255,6 +258,9 @@ importers:
'@types/tinycolor2':
specifier: ^1.4.6
version: 1.4.6
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@vitejs/plugin-react':
specifier: ^4.7.0
version: 4.7.0(vite@7.0.6(@types/node@22.15.31)(jiti@1.21.6)(terser@5.43.1)(yaml@2.7.0))
@ -3160,6 +3166,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
@ -6519,6 +6528,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
@ -10844,6 +10857,8 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@types/uuid@10.0.0': {}
'@types/uuid@9.0.8': {}
'@types/ws@8.18.1':
@ -14473,6 +14488,8 @@ snapshots:
utils-merge@1.0.1: {}
uuid@11.1.0: {}
uuid@9.0.1: {}
v8-compile-cache-lib@3.0.1: