mirror of
https://github.com/readest/readest
synced 2026-04-21 13:37:44 +00:00
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:
parent
b8bb1ee71d
commit
595608bd62
39 changed files with 2006 additions and 34 deletions
|
|
@ -5,5 +5,6 @@
|
|||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}%"
|
||||
}
|
||||
|
|
|
|||
368
apps/readest-app/src/app/library/components/KOSyncSettings.tsx
Normal file
368
apps/readest-app/src/app/library/components/KOSyncSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
482
apps/readest-app/src/app/reader/hooks/useKOSync.ts
Normal file
482
apps/readest-app/src/app/reader/hooks/useKOSync.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -57,6 +57,8 @@ export interface SectionItem {
|
|||
size: number;
|
||||
linear: string;
|
||||
location?: Location;
|
||||
|
||||
createDocument: () => Promise<Document>;
|
||||
}
|
||||
|
||||
export type BookMetadata = {
|
||||
|
|
|
|||
54
apps/readest-app/src/pages/api/kosync.ts
Normal file
54
apps/readest-app/src/pages/api/kosync.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
218
apps/readest-app/src/services/sync/KOSyncClient.ts
Normal file
218
apps/readest-app/src/services/sync/KOSyncClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
apps/readest-app/src/types/kosync.ts
Normal file
11
apps/readest-app/src/types/kosync.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue