diff --git a/.prettierrc.json b/.prettierrc.json index 9b614ef6..5f19d4e2 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -5,5 +5,6 @@ "tabWidth": 2, "singleQuote": true, "jsxSingleQuote": true, + "endOfLine": "lf", "plugins": ["prettier-plugin-tailwindcss"] } diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index f27b261f..92f514d0 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -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", diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index fcf4ea92..2693dc55 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index f62c59f0..ed52767f 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index eb5aad5f..11cb46d6 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 655d5883..0037a4f6 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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", diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 96ea291f..5e8692eb 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 4ba73eae..ee2dc7f4 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 9af875ee..39dd0605 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index fc58199e..efe0925d 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index d831cfa4..3ea5be7f 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 470ae7f8..194d2eac 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 310ab8c0..9c249696 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 3976ea53..ecd97232 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index c475c9ec..9022bb13 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 20788a43..cb57d3c3 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 5b4cdf19..5bb6cb95 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index d4cd2d5a..84d16130 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 9f24d1b1..7f1e041a 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index f4f8a700..a6e69531 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 7832d4ac..758c5998 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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}}%" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index dd7cd022..052d3312 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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}}%" } diff --git a/apps/readest-app/src/app/library/components/KOSyncSettings.tsx b/apps/readest-app/src/app/library/components/KOSyncSettings.tsx new file mode 100644 index 00000000..9f738b21 --- /dev/null +++ b/apps/readest-app/src/app/library/components/KOSyncSettings.tsx @@ -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) => void; + options: Option[]; + disabled?: boolean; + className?: string; +}; + +const StyledSelect: React.FC = ({ + value, + onChange, + options, + className, + disabled = false, +}) => { + return ( + + ); +}; + +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) => { + 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) => { + const newStrategy = e.target.value as KoreaderSyncStrategy; + const newSettings = { ...settings, koreaderSyncStrategy: newStrategy }; + setSettings(newSettings); + await saveSettings(envConfig, newSettings); + }; + + const handleChecksumMethodChange = async (e: React.ChangeEvent) => { + const newMethod = e.target.value as KoreaderSyncChecksumMethod; + const newSettings = { ...settings, koreaderSyncChecksumMethod: newMethod }; + setSettings(newSettings); + await saveSettings(envConfig, newSettings); + }; + + const handleToleranceChange = async (e: React.ChangeEvent) => { + 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 ( + setIsOpen(false)} + title={_('KOReader Sync Settings')} + boxClassName='sm:!min-w-[520px] sm:h-auto' + > +
+ {isConfigured ? ( + <> +
+

+ {_('Connected as {{username}}', { username: settings.koreaderSyncUsername })} +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {_('Precision: {{precision}} decimal places', { precision: toleranceSliderValue })} +
+
+ + ) : ( + <> +

+ {_('Connect to your KOReader Sync server.')} +

+
+ + setUrl(e.target.value)} + /> +
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ + {connectionStatus && ( +
{connectionStatus}
+ )} + + )} +
+
+ ); +}; diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index cf258794..dbae7fc5 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -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 = ({ setIsDropdownOpen }) => { setIsTelemetryEnabled(settings.telemetryEnabled); }; + const showKoSyncSettingsWindow = () => { + setKOSyncSettingsWindowVisible(true); + setIsDropdownOpen?.(false); + }; + const handleUpgrade = () => { navigateToProfile(router); setIsDropdownOpen?.(false); @@ -268,6 +274,8 @@ const SettingsMenu: React.FC = ({ setIsDropdownOpen }) => { onClick={cycleThemeMode} />
+ +
{user && userPlan === 'free' && !appService?.isIOSApp && ( )} diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 7d560a11..13cffb3b 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -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 /> )} + diff --git a/apps/readest-app/src/app/reader/components/ConfirmSyncDialog.tsx b/apps/readest-app/src/app/reader/components/ConfirmSyncDialog.tsx new file mode 100644 index 00000000..0f8e25e3 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/ConfirmSyncDialog.tsx @@ -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 = ({ + details, + onConfirmLocal, + onConfirmRemote, + onClose, +}) => { + const _ = useTranslation(); + + if (!details) return null; + + return ( + +

+ {_('Sync reading progress from "{{deviceName}}"?', { + deviceName: details.remote.device || _('another device'), + })} +

+
+ + +
+
+ ); +}; + +export default ConfirmSyncDialog; diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index d71dec94..c5cbb82e 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -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 ( -
+ <> +
+ {syncState === 'conflict' && conflictDetails && ( + + )} + ); }; diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index 83254801..c66b9859 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -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); } }; diff --git a/apps/readest-app/src/app/reader/hooks/useKOSync.ts b/apps/readest-app/src/app/reader/hooks/useKOSync.ts new file mode 100644 index 00000000..acace5a7 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useKOSync.ts @@ -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 = 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('idle'); + const [conflictDetails, setConflictDetails] = useState(null); + const [errorMessage] = useState(null); + + const syncCompletedForKey = useRef(null); + const lastPushedCfiRef = useRef(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, + }; +}; diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index d7198c67..2ec2a7b4 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -57,6 +57,8 @@ export interface SectionItem { size: number; linear: string; location?: Location; + + createDocument: () => Promise; } export type BookMetadata = { diff --git a/apps/readest-app/src/pages/api/kosync.ts b/apps/readest-app/src/pages/api/kosync.ts new file mode 100644 index 00000000..1b78b61c --- /dev/null +++ b/apps/readest-app/src/pages/api/kosync.ts @@ -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 }); + } +} diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index c3dd79dd..5721c7da 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -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 : {}), diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 1ec46e5b..fed99685 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -53,6 +53,15 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = { 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, diff --git a/apps/readest-app/src/services/sync/KOSyncClient.ts b/apps/readest-app/src/services/sync/KOSyncClient.ts new file mode 100644 index 00000000..7e28439d --- /dev/null +++ b/apps/readest-app/src/services/sync/KOSyncClient.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/readest-app/src/types/kosync.ts b/apps/readest-app/src/types/kosync.ts new file mode 100644 index 00000000..bae03022 --- /dev/null +++ b/apps/readest-app/src/types/kosync.ts @@ -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; + body?: unknown; +} diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 82cd7663..32b3f1d8 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -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; diff --git a/apps/readest-app/src/utils/debounce.ts b/apps/readest-app/src/utils/debounce.ts index c72dc81b..5181f5b0 100644 --- a/apps/readest-app/src/utils/debounce.ts +++ b/apps/readest-app/src/utils/debounce.ts @@ -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 = ) => void | Promise>( func: T, delay: number, options: DebounceOptions = { emitLast: true }, -): ((...args: Parameters) => void) => { +): ((...args: Parameters) => void) & { flush: () => void; cancel: () => void } => { let timeout: ReturnType | null = null; let lastArgs: Parameters | null = null; - return (...args: Parameters): void => { + const debounced = (...args: Parameters): void => { + lastArgs = args; if (timeout) { clearTimeout(timeout); } if (options.emitLast) { - lastArgs = args; timeout = setTimeout(() => { if (lastArgs) { func(...(lastArgs as Parameters)); @@ -35,4 +37,31 @@ export const debounce = ) => void | Promise { + if (timeout) { + clearTimeout(timeout); + timeout = null; + if (lastArgs) { + func(...(lastArgs as Parameters)); + lastArgs = null; + } + } + }; + + /** + * Cancels the pending debounced function call. + */ + debounced.cancel = () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + lastArgs = null; + } + }; + + return debounced; }; diff --git a/apps/readest-app/src/utils/xcfi.ts b/apps/readest-app/src/utils/xcfi.ts index 5d3da004..055c869c 100644 --- a/apps/readest-app/src/utils/xcfi.ts +++ b/apps/readest-app/src/utils/xcfi.ts @@ -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; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9b20c2f..46eab999 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: