Compare commits

...

24 Commits

Author SHA1 Message Date
Huang Xin f0c249bce2 release: version 0.9.76 (#1932) 2025-08-30 06:19:01 +02:00
Huang Xin e8a96fba95 compat: more compat with window insets on Android (#1931) 2025-08-30 06:18:06 +02:00
Huang Xin 92b01c40a0 layout: fix grouping modal not shown on Linux (#1930) 2025-08-30 05:28:02 +02:00
Huang Xin 6003eeadba compat: support more kosync server implementations, closes #1862 (#1929) 2025-08-30 04:54:31 +02:00
Huang Xin 856ed4d3b3 ux: add splash screen for iOS and Android and reduce flash when opening books (#1926) 2025-08-29 19:10:09 +02:00
Huang Xin 267fe58a8c fix: reduce screen flash with different background colors when app starts, closes #1915 (#1922) 2025-08-28 20:02:53 +02:00
Huang Xin 1b00d8c41c fix: support importing azw3 files on Android, closes #1920 (#1921) 2025-08-28 16:11:30 +02:00
Huang Xin cb126613b1 perf(sync): incremental sync of library books (#1917) 2025-08-27 19:04:57 +02:00
Huang Xin 515bfee2a1 fix(kosync): normalize xpointer to improve KOReader sync tolerance, closes #1857 (#1914) 2025-08-27 09:18:32 +02:00
Huang Xin 584f3511f8 refactor(kosync): move kosync into reader sidebar menu and add manual pull/push trigger (#1912) 2025-08-27 13:32:00 +08:00
Huang Xin 3ea54e6725 fix: display hidden footnotes, closes #1847 (#1907) 2025-08-26 12:45:12 +02:00
Huang Xin 48212d892f fonts: purge custom fonts when reseting fonts config (#1906) 2025-08-26 11:23:34 +02:00
Huang Xin 977c61b914 refactor: use useFileSelector hook for all file selection (#1905) 2025-08-26 10:04:10 +02:00
Huang Xin 66fceb2313 chore: add 10MB of grace bytes for storage quota (#1904) 2025-08-26 07:28:15 +02:00
Huang Xin 76feefff00 layout: fix overflow of some font names in custom fonts, closes #1901 (#1903) 2025-08-26 06:35:43 +02:00
Huang Xin 1be43ff704 fix: close reader window when trying to go to library in separate reader window, closes #1899 (#1900) 2025-08-25 18:01:06 +02:00
Huang Xin b6d4a547e5 chore: fix typo (#1898) 2025-08-25 09:41:36 +02:00
Huang Xin 5ee860f1e8 refactor: use safe insets from native for Android, closes #1793 and closes #1886 (#1897) 2025-08-25 09:38:49 +02:00
dependabot[bot] 0b01132d88 chore(deps): bump actions/setup-java in the github-actions group (#1896)
Bumps the github-actions group with 1 update: [actions/setup-java](https://github.com/actions/setup-java).


Updates `actions/setup-java` from 4 to 5
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-25 09:13:58 +02:00
Huang Xin b98c487919 theme: fix themed background color for PDFs on macOS, closes #1887 (#1894) 2025-08-24 08:29:45 +02:00
Huang Xin b43b08d423 fix: init traffic light properly in library page on macOS, closes #1872 (#1891) 2025-08-24 06:33:52 +02:00
Huang Xin 3ddb6e6422 fix: handle some cases where footnote is empty, closes #1880 (#1883) 2025-08-23 10:28:51 +02:00
Huang Xin ea9146be5b fonts: parse font family as well as font style when importing fonts, closes #1876 (#1881) 2025-08-23 10:27:58 +02:00
Huang Xin 78cb1f45e4 chore: fix workflow dependency for release (#1874) 2025-08-22 17:51:01 +02:00
104 changed files with 1306 additions and 975 deletions
+2 -2
View File
@@ -142,7 +142,7 @@ jobs:
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
@@ -322,7 +322,7 @@ jobs:
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
upload-to-r2:
needs: [get-release, update-release]
needs: [get-release, build-tauri]
uses: ./.github/workflows/upload-to-r2.yml
with:
tag: ${{ needs.get-release.outputs.release_tag }}
Generated
+4 -4
View File
@@ -5337,7 +5337,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.8.2"
version = "2.8.4"
dependencies = [
"anyhow",
"bytes",
@@ -5810,7 +5810,7 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.8.0"
version = "2.8.1"
dependencies = [
"gtk",
"http",
@@ -7249,9 +7249,9 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "wry"
version = "0.53.1"
version = "0.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5698e50a589268aec06d2219f48b143222f7b5ad9aa690118b8dce0a8dcac574"
checksum = "31f0e9642a0d061f6236c54ccae64c2722a7879ad4ec7dff59bd376d446d8e90"
dependencies = [
"base64 0.22.1",
"block2 0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.75",
"version": "0.9.76",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -459,7 +459,6 @@
"Google Books": "كتب جوجل",
"Open Library": "المكتبة المفتوحة",
"Fiction, Science, History": "خيال، علوم، تاريخ",
"Select Cover Image": "اختر صورة الغلاف",
"Open Book in New Window": "فتح الكتاب في نافذة جديدة",
"Voices for {{lang}}": "الأصوات لـ {{lang}}",
"Yandex Translate": "ترجمة Yandex",
@@ -500,7 +499,6 @@
"File Content (recommended)": "محتوى الملف (موصى به)",
"File Name": "اسم الملف",
"Device Name": "اسم الجهاز",
"Sync Tolerance": "تحمل المزامنة",
"Connect to your KOReader Sync server.": "الاتصال بخادم مزامنة KOReader الخاص بك.",
"Server URL": "عنوان URL للخادم",
"Username": "اسم المستخدم",
@@ -519,7 +517,6 @@
"Approximately {{percentage}}%": "تقريبًا {{percentage}}%",
"Failed to connect": "فشل في الاتصال",
"Sync Server Connected": "تم الاتصال بخادم المزامنة",
"Precision: {{precision}} digits after the decimal": "الدقة: {{precision}} أرقام بعد الفاصلة",
"Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}",
"Custom Fonts": "الخطوط المخصصة",
"Cancel Delete": "إلغاء الحذف",
@@ -533,5 +530,7 @@
"Select Video": "حدد فيديو",
"Select Audio": "حدد صوت",
"Select Fonts": "حدد الخطوط",
"Supported font formats: .ttf, .odf, .woff, .woff2": "تنسيقات الخط المدعومة: .ttf، .odf، .woff، .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "تنسيقات الخط المدعومة: .ttf، .otf، .woff، .woff2",
"Push Progress": "تقدم الدفع",
"Pull Progress": "تقدم السحب"
}
@@ -438,7 +438,6 @@
"Google Books": "Google དཔེ་མཛོད།",
"Open Library": "སྒོ་འབྱེད་དཔེ་ཁང་།",
"Fiction, Science, History": "སྒྲུང་གཏམ། ཚན་རིག ལོ་རྒྱུས།",
"Select Cover Image": "སྟོང་ངོས་ཀྱི་པར་རིས་འདེམས་པ།",
"Open Book in New Window": "སྒེའུ་ཁུང་གསར་པའི་ནང་དཔེ་དེབ་ཁ་ཕྱེ་བ།",
"Voices for {{lang}}": "{{lang}} གྱི་སྒྲ་སྐད།",
"Yandex Translate": "Yandex སྒྱུར་བཅོས།",
@@ -482,8 +481,6 @@
"File Content (recommended)": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"File Name": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Device Name": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Sync Tolerance": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Precision: {{precision}} digits after the decimal": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Connect to your KOReader Sync server.": "ཁྱེད་ཀྱིས KOReader སྤྲི་དོན་སང་བཞིན་འདེམས་པ།",
"Server URL": "སང་བཞིན་འདེམས་པའི URL",
"Username": "ཁྱེད་ཀྱིས་འབྱུང་ཁུངས་འདེམས་པ།",
@@ -513,5 +510,7 @@
"Select Video": "བརྙན་ཟློས་འདེམས་པ།",
"Select Audio": "སྒྲ་ཟློས་འདེམས་པ།",
"Select Fonts": "ཡིག་གཟུགས་འདེམས་པ།",
"Supported font formats: .ttf, .odf, .woff, .woff2": "ཡིག་གཟུགས་འདེམས་པ་བྱས་ནས་བསྐྱར་བརྗེ་བ། ཡིག་གཟུགས་ཀྱི་སྒྲ་སྐད། .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "ཡིག་གཟུགས་འདེམས་པ་བྱས་ནས་བསྐྱར་བརྗེ་བ། ཡིག་གཟུགས་ཀྱི་སྒྲ་སྐད། .ttf, .otf, .woff, .woff2",
"Push Progress": "འདེགས་པའི་འཕེལ་རིམ།",
"Pull Progress": "འདྲུད་པའི་འཕེལ་རིམ།"
}
@@ -443,7 +443,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Belletristik, Wissenschaft, Geschichte",
"Select Cover Image": "Coverbild auswählen",
"Open Book in New Window": "Buch in neuem Fenster öffnen",
"Voices for {{lang}}": "Stimmen für {{lang}}",
"Yandex Translate": "Yandex Übersetzer",
@@ -484,7 +483,6 @@
"File Content (recommended)": "Dateiinhalte (empfohlen)",
"File Name": "Dateiname",
"Device Name": "Gerätename",
"Sync Tolerance": "Sync-Toleranz",
"Connect to your KOReader Sync server.": "Mit Ihrem KOReader Sync-Server verbinden.",
"Server URL": "Server-URL",
"Username": "Benutzername",
@@ -503,7 +501,6 @@
"Approximately {{percentage}}%": "Ungefähr {{percentage}}%",
"Failed to connect": "Verbindung fehlgeschlagen",
"Sync Server Connected": "Sync-Server verbunden",
"Precision: {{precision}} digits after the decimal": "Präzision: {{precision}} Nachkommastellen",
"Sync as {{userDisplayName}}": "Sync als {{userDisplayName}}",
"Custom Fonts": "Benutzerdefinierte Schriftarten",
"Cancel Delete": "Löschen abbrechen",
@@ -517,5 +514,7 @@
"Select Video": "Video auswählen",
"Select Audio": "Audio auswählen",
"Select Fonts": "Schriftarten auswählen",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Unterstützte Schriftartenformate: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Unterstützte Schriftartenformate: .ttf, .otf, .woff, .woff2",
"Push Progress": "Fortschritt senden",
"Pull Progress": "Fortschritt empfangen"
}
@@ -443,7 +443,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Μυθοπλασία, Επιστήμη, Ιστορία",
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου",
"Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο",
"Voices for {{lang}}": "Φωνές για {{lang}}",
"Yandex Translate": "Μετάφραση Yandex",
@@ -484,7 +483,6 @@
"File Content (recommended)": "Περιεχόμενο αρχείου (συνιστάται)",
"File Name": "Όνομα αρχείου",
"Device Name": "Όνομα συσκευής",
"Sync Tolerance": "Ανοχή συγχρονισμού",
"Connect to your KOReader Sync server.": "Συνδεθείτε στον διακομιστή συγχρονισμού KOReader.",
"Server URL": "Διεύθυνση URL διακομιστή",
"Username": "Όνομα χρήστη",
@@ -503,7 +501,6 @@
"Approximately {{percentage}}%": "Περίπου {{percentage}}%",
"Failed to connect": "Αποτυχία σύνδεσης",
"Sync Server Connected": "Ο διακομιστής συγχρονισμού είναι συνδεδεμένος",
"Precision: {{precision}} digits after the decimal": "Ακρίβεια: {{precision}} ψηφία μετά την υποδιαστολή",
"Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}",
"Custom Fonts": "Προσαρμοσμένες Γραμματοσειρές",
"Cancel Delete": "Ακύρωση Διαγραφής",
@@ -517,5 +514,7 @@
"Select Video": "Επιλογή Βίντεο",
"Select Audio": "Επιλογή Ήχου",
"Select Fonts": "Επιλογή Γραμματοσειρών",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Υποστηριζόμενες μορφές γραμματοσειρών: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Υποστηριζόμενες μορφές γραμματοσειρών: .ttf, .otf, .woff, .woff2",
"Push Progress": "Αποστολή προόδου",
"Pull Progress": "Λήψη προόδου"
}
@@ -88,7 +88,6 @@
"File Content (recommended)": "Contenido del archivo (recomendado)",
"File Name": "Nombre del archivo",
"Device Name": "Nombre del dispositivo",
"Sync Tolerance": "Tolerancia de sincronización",
"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}}\"?",
@@ -476,7 +475,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Ficción, Ciencia, Historia",
"Select Cover Image": "Seleccionar imagen de portada",
"Open Book in New Window": "Abre el libro en una nueva ventana",
"Voices for {{lang}}": "Voces para {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -507,7 +505,6 @@
"Remove from Device Only": "Eliminar solo del dispositivo",
"Failed to connect": "Error al conectar",
"Sync Server Connected": "Servidor de sincronización conectado",
"Precision: {{precision}} digits after the decimal": "Precisión: {{precision}} dígitos después del decimal",
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}",
"Custom Fonts": "Fuentes Personalizadas",
"Cancel Delete": "Cancelar Eliminación",
@@ -521,5 +518,7 @@
"Select Video": "Seleccionar Video",
"Select Audio": "Seleccionar Audio",
"Select Fonts": "Seleccionar Fuentes",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Formatos de fuente compatibles: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formatos de fuente compatibles: .ttf, .otf, .woff, .woff2",
"Push Progress": "Empujar progreso",
"Pull Progress": "Tirar progreso"
}
@@ -447,7 +447,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Fiction, Science, Histoire",
"Select Cover Image": "Sélectionner l'image de couverture",
"Open Book in New Window": "Ouvrir dans une nouvelle fenêtre",
"Voices for {{lang}}": "Voix pour {{lang}}",
"Yandex Translate": "Yandex Traduction",
@@ -488,7 +487,6 @@
"File Content (recommended)": "Contenu du fichier (recommandé)",
"File Name": "Nom du fichier",
"Device Name": "Nom de l'appareil",
"Sync Tolerance": "Tolérance de synchronisation",
"Connect to your KOReader Sync server.": "Connectez-vous à votre serveur de synchronisation KOReader.",
"Server URL": "URL du serveur",
"Username": "Nom d'utilisateur",
@@ -507,7 +505,6 @@
"Approximately {{percentage}}%": "Environ {{percentage}}%",
"Failed to connect": "Échec de la connexion",
"Sync Server Connected": "Serveur de synchronisation connecté",
"Precision: {{precision}} digits after the decimal": "Précision : {{precision}} chiffres après la virgule",
"Sync as {{userDisplayName}}": "Synchroniser en tant que {{userDisplayName}}",
"Custom Fonts": "Polices Personnalisées",
"Cancel Delete": "Annuler la Suppression",
@@ -521,5 +518,7 @@
"Select Video": "Sélectionner une Vidéo",
"Select Audio": "Sélectionner un Audio",
"Select Fonts": "Sélectionner des Polices",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Formats de police pris en charge : .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formats de police pris en charge : .ttf, .otf, .woff, .woff2",
"Push Progress": "Pousser la Progression",
"Pull Progress": "Tirer la Progression"
}
@@ -443,7 +443,6 @@
"Google Books": "गूगल बुक्स",
"Open Library": "ओपन लाइब्रेरी",
"Fiction, Science, History": "काल्पनिक, विज्ञान, इतिहास",
"Select Cover Image": "कवर इमेज चुनें",
"Open Book in New Window": "नई विंडो में पुस्तक खोलें",
"Voices for {{lang}}": "{{lang}} के लिए आवाज़ें",
"Yandex Translate": "यांडेक्स अनुवाद",
@@ -484,7 +483,6 @@
"File Content (recommended)": "फाइल सामग्री (अनुशंसित)",
"File Name": "फाइल नाम",
"Device Name": "डिवाइस नाम",
"Sync Tolerance": "सिंक सहिष्णुता",
"Connect to your KOReader Sync server.": "अपने KOReader सिंक सर्वर से कनेक्ट करें।",
"Server URL": "सर्वर यूआरएल",
"Username": "उपयोगकर्ता नाम",
@@ -503,7 +501,6 @@
"Approximately {{percentage}}%": "लगभग {{percentage}}%",
"Failed to connect": "कनेक्ट करने में विफल",
"Sync Server Connected": "सिंक सर्वर कनेक्टेड",
"Precision: {{precision}} digits after the decimal": "सटीकता: {{precision}} दशमलव के बाद अंक",
"Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}",
"Custom Fonts": "कस्टम फ़ॉन्ट",
"Cancel Delete": "डिलीट रद्द करें",
@@ -517,5 +514,7 @@
"Select Video": "वीडियो चुनें",
"Select Audio": "ऑडियो चुनें",
"Select Fonts": "फ़ॉन्ट चुनें",
"Supported font formats: .ttf, .odf, .woff, .woff2": "समर्थित फ़ॉन्ट प्रारूप: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "समर्थित फ़ॉन्ट प्रारूप: .ttf, .otf, .woff, .woff2",
"Push progress": "पुश प्रगति",
"Pull progress": "पुल प्रगति"
}
@@ -439,7 +439,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Fiksi, Sains, Sejarah",
"Select Cover Image": "Pilihan Gambar Sampul",
"Open Book in New Window": "Buka Buku di Jendela Baru",
"Voices for {{lang}}": "Suara untuk {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -480,7 +479,6 @@
"File Content (recommended)": "Konten File (disarankan)",
"File Name": "Nama File",
"Device Name": "Nama Perangkat",
"Sync Tolerance": "Toleransi Sinkronisasi",
"Connect to your KOReader Sync server.": "Hubungkan ke server Sinkronisasi KOReader Anda.",
"Server URL": "URL Server",
"Username": "Nama Pengguna",
@@ -499,7 +497,6 @@
"Approximately {{percentage}}%": "Sekitar {{percentage}}%",
"Failed to connect": "Gagal terhubung",
"Sync Server Connected": "Server sinkronisasi terhubung",
"Precision: {{precision}} digits after the decimal": "Presisi: {{precision}} digit setelah desimal",
"Sync as {{userDisplayName}}": "Sinkronkan sebagai {{userDisplayName}}",
"Custom Fonts": "Font Kustom",
"Cancel Delete": "Batal Hapus",
@@ -513,5 +510,7 @@
"Select Video": "Pilih Video",
"Select Audio": "Pilih Audio",
"Select Fonts": "Pilih Font",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Format font yang didukung: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Format font yang didukung: .ttf, .otf, .woff, .woff2",
"Push Progress": "Dorong Progres",
"Pull Progress": "Tarik Progres"
}
@@ -447,7 +447,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Narrativa, Scienza, Storia",
"Select Cover Image": "Seleziona immagine di copertina",
"Open Book in New Window": "Apri libro in una nuova finestra",
"Voices for {{lang}}": "Voci per {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -488,7 +487,6 @@
"File Content (recommended)": "Contenuto del File (consigliato)",
"File Name": "Nome del File",
"Device Name": "Nome del Dispositivo",
"Sync Tolerance": "Tolleranza di Sincronizzazione",
"Connect to your KOReader Sync server.": "Connettiti al tuo server KOReader Sync.",
"Server URL": "URL del Server",
"Username": "Nome Utente",
@@ -507,7 +505,6 @@
"Approximately {{percentage}}%": "Circa {{percentage}}%",
"Failed to connect": "Impossibile connettersi",
"Sync Server Connected": "Server di sincronizzazione connesso",
"Precision: {{precision}} digits after the decimal": "Precisione: {{precision}} cifre dopo la virgola",
"Sync as {{userDisplayName}}": "Sincronizza come {{userDisplayName}}",
"Custom Fonts": "Font Personalizzati",
"Cancel Delete": "Annulla Eliminazione",
@@ -521,5 +518,7 @@
"Select Video": "Seleziona Video",
"Select Audio": "Seleziona Audio",
"Select Fonts": "Seleziona Font",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Formati di font supportati: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formati di font supportati: .ttf, .otf, .woff, .woff2",
"Push progress": "Progresso push",
"Pull progress": "Progresso pull"
}
@@ -439,7 +439,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "小説、科学、歴史",
"Select Cover Image": "表紙画像を選択",
"Open Book in New Window": "新しいウィンドウで本を開く",
"Voices for {{lang}}": "{{lang}}の音声",
"Yandex Translate": "Yandex翻訳",
@@ -480,7 +479,6 @@
"File Content (recommended)": "ファイル内容(推奨)",
"File Name": "ファイル名",
"Device Name": "デバイス名",
"Sync Tolerance": "同期許容範囲",
"Connect to your KOReader Sync server.": "KOReader Syncサーバーに接続します。",
"Server URL": "サーバーURL",
"Username": "ユーザー名",
@@ -499,7 +497,6 @@
"Approximately {{percentage}}%": "おおよそ {{percentage}}%",
"Failed to connect": "接続に失敗しました",
"Sync Server Connected": "同期サーバーに接続されました",
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 桁の小数点以下",
"Sync as {{userDisplayName}}": "{{userDisplayName}} として同期",
"Custom Fonts": "カスタムフォント",
"Cancel Delete": "削除をキャンセル",
@@ -513,5 +510,7 @@
"Select Video": "動画を選択",
"Select Audio": "音声を選択",
"Select Fonts": "フォントを選択",
"Supported font formats: .ttf, .odf, .woff, .woff2": "サポートされているフォント形式:.ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "サポートされているフォント形式:.ttf, .otf, .woff, .woff2",
"Push Progress": "進捗をプッシュ",
"Pull Progress": "進捗をプル"
}
@@ -439,7 +439,6 @@
"Google Books": "구글 북스",
"Open Library": "오픈 라이브러리",
"Fiction, Science, History": "소설, 과학, 역사",
"Select Cover Image": "표지 이미지 선택",
"Open Book in New Window": "새 창에서 책 열기",
"Voices for {{lang}}": "{{lang}}의 음성",
"Yandex Translate": "Yandex 번역",
@@ -480,7 +479,6 @@
"File Content (recommended)": "파일 내용 (권장)",
"File Name": "파일 이름",
"Device Name": "장치 이름",
"Sync Tolerance": "동기화 허용 오차",
"Connect to your KOReader Sync server.": "KOReader Sync 서버에 연결합니다.",
"Server URL": "서버 URL",
"Username": "사용자 이름",
@@ -499,7 +497,6 @@
"Approximately {{percentage}}%": "대략 {{percentage}}%",
"Failed to connect": "연결 실패",
"Sync Server Connected": "동기화 서버에 연결됨",
"Precision: {{precision}} digits after the decimal": "정밀도: {{precision}} 자리의 소수점 이하",
"Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화",
"Custom Fonts": "사용자 정의 글꼴",
"Cancel Delete": "삭제 취소",
@@ -513,5 +510,7 @@
"Select Video": "동영상 선택",
"Select Audio": "오디오 선택",
"Select Fonts": "글꼴 선택",
"Supported font formats: .ttf, .odf, .woff, .woff2": "지원되는 글꼴 형식: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "지원되는 글꼴 형식: .ttf, .otf, .woff, .woff2",
"Push Progress": "진행 상황 푸시",
"Pull Progress": "진행 상황 끌어오기"
}
@@ -443,7 +443,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Fictie, Wetenschap, Geschiedenis",
"Select Cover Image": "Selecteer omslagafbeelding",
"Open Book in New Window": "Open boek in nieuw venster",
"Voices for {{lang}}": "Stemmen voor {{lang}}",
"Yandex Translate": "Yandex Vertalen",
@@ -484,7 +483,6 @@
"File Content (recommended)": "Bestandsinhoud (aanbevolen)",
"File Name": "Bestandsnaam",
"Device Name": "Apparaatnaam",
"Sync Tolerance": "Synchronisatietolerantie",
"Connect to your KOReader Sync server.": "Verbind met je KOReader Sync-server.",
"Server URL": "Server-URL",
"Username": "Gebruikersnaam",
@@ -503,7 +501,6 @@
"Approximately {{percentage}}%": "Ongeveer {{percentage}}%",
"Failed to connect": "Verbinding mislukt",
"Sync Server Connected": "Synchronisatie server verbonden",
"Precision: {{precision}} digits after the decimal": "Precisie: {{precision}} cijfers na de komma",
"Sync as {{userDisplayName}}": "Synchroniseren als {{userDisplayName}}",
"Custom Fonts": "Aangepaste Lettertypen",
"Cancel Delete": "Verwijderen Annuleren",
@@ -517,5 +514,7 @@
"Select Video": "Video Selecteren",
"Select Audio": "Audio Selecteren",
"Select Fonts": "Lettertypen Selecteren",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Ondersteunde lettertypeformaten: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Ondersteunde lettertypeformaten: .ttf, .otf, .woff, .woff2",
"Push Progress": "Voortgang pushen",
"Pull Progress": "Voortgang pullen"
}
@@ -451,7 +451,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Beletrystyka, Nauka, Historia",
"Select Cover Image": "Zaznacz obraz okładki",
"Open Book in New Window": "Otwórz książkę w nowym oknie",
"Voices for {{lang}}": "{{lang}} Głosy",
"Yandex Translate": "Yandex Tłumacz",
@@ -492,7 +491,6 @@
"File Content (recommended)": "Zawartość pliku (zalecane)",
"File Name": "Nazwa pliku",
"Device Name": "Nazwa urządzenia",
"Sync Tolerance": "Tolerancja synchronizacji",
"Connect to your KOReader Sync server.": "Połącz z serwerem synchronizacji KOReader.",
"Server URL": "Adres URL serwera",
"Username": "Nazwa użytkownika",
@@ -511,7 +509,6 @@
"Approximately {{percentage}}%": "Około {{percentage}}%",
"Failed to connect": "Nie udało się połączyć",
"Sync Server Connected": "Serwer synchronizacji połączony",
"Precision: {{precision}} digits after the decimal": "Precyzja: {{precision}} cyfr po przecinku",
"Sync as {{userDisplayName}}": "Synchronizuj jako {{userDisplayName}}",
"Custom Fonts": "Niestandardowe Czcionki",
"Cancel Delete": "Anuluj Usuwanie",
@@ -525,5 +522,7 @@
"Select Video": "Wybierz Wideo",
"Select Audio": "Wybierz Audio",
"Select Fonts": "Wybierz Czcionki",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Obsługiwane formaty czcionek: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Obsługiwane formaty czcionek: .ttf, .otf, .woff, .woff2",
"Push Progress": "Wyślij postęp",
"Pull Progress": "Pobierz postęp"
}
@@ -447,7 +447,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Ficção, Ciência, História",
"Select Cover Image": "Selecionar imagem da capa",
"Open Book in New Window": "Abrir livro em nova janela",
"Voices for {{lang}}": "Vozes para {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -488,7 +487,6 @@
"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",
"Connect to your KOReader Sync server.": "Conectar ao seu servidor KOReader Sync.",
"Server URL": "URL do Servidor",
"Username": "Nome de Usuário",
@@ -507,7 +505,6 @@
"Approximately {{percentage}}%": "Aproximadamente {{percentage}}%",
"Failed to connect": "Falha ao conectar",
"Sync Server Connected": "Servidor de Sincronização Conectado",
"Precision: {{precision}} digits after the decimal": "Precisão: {{precision}} dígitos após a vírgula",
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}",
"Custom Fonts": "Fontes Personalizadas",
"Cancel Delete": "Cancelar Exclusão",
@@ -521,5 +518,7 @@
"Select Video": "Selecionar Vídeo",
"Select Audio": "Selecionar Áudio",
"Select Fonts": "Selecionar Fontes",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Formatos de fonte suportados: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formatos de fonte suportados: .ttf, .otf, .woff, .woff2",
"Push Progress": "Enviar Progresso",
"Pull Progress": "Obter Progresso"
}
@@ -451,7 +451,6 @@
"Google Books": "Google Книги",
"Open Library": "Open Library",
"Fiction, Science, History": "Художественная литература, Наука, История",
"Select Cover Image": "Выбрать изображение обложки",
"Open Book in New Window": "Открыть книгу в новом окне",
"Voices for {{lang}}": "Голоса для {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -492,7 +491,6 @@
"File Content (recommended)": "Содержимое файла (рекомендуется)",
"File Name": "Имя файла",
"Device Name": "Имя устройства",
"Sync Tolerance": "Допуск синхронизации",
"Connect to your KOReader Sync server.": "Подключитесь к вашему серверу синхронизации KOReader.",
"Server URL": "URL сервера",
"Username": "Имя пользователя",
@@ -511,7 +509,6 @@
"Approximately {{percentage}}%": "Приблизительно {{percentage}}%",
"Failed to connect": "Не удалось подключиться",
"Sync Server Connected": "Сервер синхронизации подключен",
"Precision: {{precision}} digits after the decimal": "Точность: {{precision}} знаков после запятой",
"Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}",
"Custom Fonts": "Пользовательские Шрифты",
"Cancel Delete": "Отменить Удаление",
@@ -525,5 +522,7 @@
"Select Video": "Выбрать Видео",
"Select Audio": "Выбрать Аудио",
"Select Fonts": "Выбрать Шрифты",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Поддерживаемые форматы шрифтов: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Поддерживаемые форматы шрифтов: .ttf, .otf, .woff, .woff2",
"Push Progress": "Отправить прогресс",
"Pull Progress": "Получить прогресс"
}
@@ -439,7 +439,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์",
"Select Cover Image": "เลือกภาพปก",
"Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่",
"Voices for {{lang}}": "เสียงสำหรับ {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -480,7 +479,6 @@
"File Content (recommended)": "เนื้อหาไฟล์ (แนะนำ)",
"File Name": "ชื่อไฟล์",
"Device Name": "ชื่ออุปกรณ์",
"Sync Tolerance": "ความทนทานต่อการซิงค์",
"Connect to your KOReader Sync server.": "เชื่อมต่อกับเซิร์ฟเวอร์ KOReader Sync ของคุณ",
"Server URL": "URL ของเซิร์ฟเวอร์",
"Username": "ชื่อผู้ใช้",
@@ -499,7 +497,6 @@
"Approximately {{percentage}}%": "ประมาณ {{percentage}}%",
"Failed to connect": "ไม่สามารถเชื่อมต่อได้",
"Sync Server Connected": "เซิร์ฟเวอร์ซิงค์เชื่อมต่อแล้ว",
"Precision: {{precision}} digits after the decimal": "ความแม่นยำ: {{precision}} หลักหลังจุดทศนิยม",
"Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}",
"Custom Fonts": "ฟอนต์กำหนดเอง",
"Cancel Delete": "ยกเลิกการลบ",
@@ -513,5 +510,7 @@
"Select Video": "เลือกวิดีโอ",
"Select Audio": "เลือกเสียง",
"Select Fonts": "เลือกฟอนต์",
"Supported font formats: .ttf, .odf, .woff, .woff2": "รูปแบบฟอนต์ที่รองรับ: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "รูปแบบฟอนต์ที่รองรับ: .ttf, .otf, .woff, .woff2",
"Push Progress": "ส่งความคืบหน้า",
"Pull Progress": "ดึงความคืบหน้า"
}
@@ -443,7 +443,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Kurgu, Bilim, Tarih",
"Select Cover Image": "Kapak Resmi Seç",
"Open Book in New Window": "Kitabı Yeni Pencerede Aç",
"Voices for {{lang}}": "{{lang}} için Sesler",
"Yandex Translate": "Yandex Çeviri",
@@ -484,7 +483,6 @@
"File Content (recommended)": "Dosya İçeriği (önerilen)",
"File Name": "Dosya Adı",
"Device Name": "Cihaz Adı",
"Sync Tolerance": "Senkronizasyon Toleransı",
"Connect to your KOReader Sync server.": "KOReader Senkronizasyon sunucunuza bağlanın.",
"Server URL": "Sunucu URL'si",
"Username": "Kullanıcı Adı",
@@ -503,7 +501,6 @@
"Approximately {{percentage}}%": "Yaklaşık {{percentage}}%",
"Failed to connect": "Bağlanma başarısız oldu",
"Sync Server Connected": "Senkronizasyon Sunucusu Bağlandı",
"Precision: {{precision}} digits after the decimal": "Hassasiyet: {{precision}} ondalık basamaktan sonra",
"Sync as {{userDisplayName}}": "Senkronize et {{userDisplayName}} olarak",
"Custom Fonts": "Özel Yazı Tipleri",
"Cancel Delete": "Silmeyi İptal Et",
@@ -517,5 +514,7 @@
"Select Video": "Video Seç",
"Select Audio": "Ses Seç",
"Select Fonts": "Yazı Tiplerini Seç",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Desteklenen yazı tipi formatları: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Desteklenen yazı tipi formatları: .ttf, .otf, .woff, .woff2",
"Push Progress": "İlerlemeyi Gönder",
"Pull Progress": "İlerlemeyi Al"
}
@@ -451,7 +451,6 @@
"Google Books": "Google Книги",
"Open Library": "Open Library",
"Fiction, Science, History": "Художня література, Наукова фантастика, Історія",
"Select Cover Image": "Оберіть зображення обкладинки",
"Open Book in New Window": "Відкрити книгу в новому вікні",
"Voices for {{lang}}": "Голоси для {{lang}}",
"Yandex Translate": "Yandex Перекладач (росія)",
@@ -492,7 +491,6 @@
"File Content (recommended)": "Вміст файлу (рекомендується)",
"File Name": "Ім'я файлу",
"Device Name": "Ім'я пристрою",
"Sync Tolerance": "Допустима похибка синхронізації",
"Connect to your KOReader Sync server.": "Підключіться до свого сервера синхронізації KOReader.",
"Server URL": "URL сервера",
"Username": "Ім'я користувача",
@@ -511,7 +509,6 @@
"Approximately {{percentage}}%": "Приблизно {{percentage}}%",
"Failed to connect": "Не вдалося під'єднатися",
"Sync Server Connected": "Сервер синхронізації під'єднано",
"Precision: {{precision}} digits after the decimal": "Точність: {{precision}} знаків після коми",
"Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}",
"Custom Fonts": "Користувацькі Шрифти",
"Cancel Delete": "Скасувати Видалення",
@@ -525,5 +522,7 @@
"Select Video": "Вибрати Відео",
"Select Audio": "Вибрати Аудіо",
"Select Fonts": "Вибрати Шрифти",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Підтримувані формати шрифтів: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Підтримувані формати шрифтів: .ttf, .otf, .woff, .woff2",
"Push Progress": "Надіслати прогрес",
"Pull Progress": "Отримати прогрес"
}
@@ -439,7 +439,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Tiểu thuyết, Khoa học, Lịch sử",
"Select Cover Image": "Chọn ảnh bìa",
"Open Book in New Window": "Mở Sách trong Cửa sổ Mới",
"Voices for {{lang}}": "Giọng nói cho {{lang}}",
"Yandex Translate": "Yandex Dịch",
@@ -480,7 +479,6 @@
"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ộ",
"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",
@@ -499,7 +497,6 @@
"Approximately {{percentage}}%": "Khoảng {{percentage}}%",
"Failed to connect": "Không thể kết nối",
"Sync Server Connected": "Máy chủ đồng bộ đã kết nối",
"Precision: {{precision}} digits after the decimal": "Độ chính xác: {{precision}} chữ số sau dấu thập phân",
"Sync as {{userDisplayName}}": "Đồng bộ dưới tên {{userDisplayName}}",
"Custom Fonts": "Phông Chữ Tùy Chỉnh",
"Cancel Delete": "Hủy Xóa",
@@ -513,5 +510,7 @@
"Select Video": "Chọn Video",
"Select Audio": "Chọn Âm Thanh",
"Select Fonts": "Chọn Phông Chữ",
"Supported font formats: .ttf, .odf, .woff, .woff2": "Định dạng phông chữ được hỗ trợ: .ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "Định dạng phông chữ được hỗ trợ: .ttf, .otf, .woff, .woff2",
"Push Progress": "Đẩy tiến độ",
"Pull Progress": "Kéo tiến độ"
}
@@ -439,7 +439,6 @@
"Google Books": "谷歌图书",
"Open Library": "开放图书馆",
"Fiction, Science, History": "小说,科学,历史",
"Select Cover Image": "选择封面图片",
"Open Book in New Window": "在新窗口中打开书籍",
"Voices for {{lang}}": "{{lang}} 的语音",
"Yandex Translate": "Yandex 翻译",
@@ -480,7 +479,6 @@
"File Content (recommended)": "文件内容(推荐)",
"File Name": "文件名",
"Device Name": "设备名称",
"Sync Tolerance": "同步容忍度",
"Connect to your KOReader Sync server.": "连接到您的 KOReader 同步服务器。",
"Server URL": "服务器 URL",
"Username": "用户名",
@@ -499,7 +497,6 @@
"Approximately {{percentage}}%": "大约 {{percentage}}%",
"Failed to connect": "连接失败",
"Sync Server Connected": "同步服务器已连接",
"Precision: {{precision}} digits after the decimal": "精度:{{precision}} 位小数",
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步",
"Custom Fonts": "自定义字体",
"Cancel Delete": "取消删除",
@@ -513,5 +510,7 @@
"Select Video": "选择视频",
"Select Audio": "选择音频",
"Select Fonts": "选择字体",
"Supported font formats: .ttf, .odf, .woff, .woff2": "支持的字体格式:.ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "支持的字体格式:.ttf, .otf, .woff, .woff2",
"Push Progress": "推送阅读进度",
"Pull Progress": "拉取阅读进度"
}
@@ -439,7 +439,6 @@
"Google Books": "Google 圖書",
"Open Library": "開放圖書館",
"Fiction, Science, History": "小說,科學,歷史",
"Select Cover Image": "選擇封面圖片",
"Open Book in New Window": "在新視窗中打開書籍",
"Voices for {{lang}}": "{{lang}} 的語音",
"Yandex Translate": "Yandex 翻譯",
@@ -480,7 +479,6 @@
"File Content (recommended)": "文件內容(推薦)",
"File Name": "文件名",
"Device Name": "設備名稱",
"Sync Tolerance": "同步容忍度",
"Connect to your KOReader Sync server.": "連接到您的 KOReader 同步伺服器。",
"Server URL": "伺服器 URL",
"Username": "用戶名",
@@ -499,7 +497,6 @@
"Approximately {{percentage}}%": "大約 {{percentage}}%",
"Failed to connect": "連接失敗",
"Sync Server Connected": "同步伺服器已連接",
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 位小數",
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步",
"Custom Fonts": "自訂字型",
"Cancel Delete": "取消刪除",
@@ -513,5 +510,7 @@
"Select Video": "選擇影片",
"Select Audio": "選擇音訊",
"Select Fonts": "選擇字型",
"Supported font formats: .ttf, .odf, .woff, .woff2": "支援的字型格式:.ttf, .odf, .woff, .woff2"
"Supported font formats: .ttf, .otf, .woff, .woff2": "支援的字型格式:.ttf, .otf, .woff, .woff2",
"Push Progress": "推送閱讀進度",
"Pull Progress": "拉取閱讀進度"
}
+14
View File
@@ -1,5 +1,19 @@
{
"releases": {
"0.9.76": {
"date": "2025-08-30",
"notes": [
"Sync: Moved KOReader sync controls into the reader sidebar with a manual push/pull option",
"Sync: Improved tolerance and reliability for KOReader sync operations",
"Sync: Added support for more KOReader server implementations",
"Fonts: Added support for importing multiple styles within a font family",
"Reader: Fixed an issue where footnotes are empty in some EPUB files",
"Theme: Applied theme background color for PDFs on macOS",
"Reader: Added support for importing AZW3 files on Android",
"Reader: Reduced screen flash when launching the app and opening books",
"Layout: Fixed an issue where the grouping modal would not appear on Linux"
]
},
"0.9.75": {
"date": "2025-08-22",
"notes": [
@@ -30,27 +30,55 @@
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/epub+zip" />
<data android:mimeType="application/pdf" />
<data android:mimeType="application/fb2" />
<data android:mimeType="application/x-fb2" />
<data android:mimeType="application/mobi"/>
<data android:mimeType="application/azw"/>
<data android:mimeType="application/azw3"/>
<data android:mimeType="application/x-mobipocket-ebook" />
<data android:mimeType="application/vnd.amazon.ebook" />
<data android:mimeType="application/vnd.amazon.mobi8-ebook" />
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/x-cbz" />
<data android:mimeType="application/x-mobipocket-ebook" />
<data android:mimeType="application/zip" />
<data android:mimeType="text/plain" />
<data android:mimeType="application/octet-stream"/>
<data android:mimeType="application/x-font-ttf"/>
<data android:mimeType="application/x-font-otf"/>
</intent-filter>
<data android:pathPattern="/.*\\.epub" />
<data android:pathPattern="/.*\\.pdf" />
<data android:pathPattern="/.*\\.fb2" />
<data android:pathPattern="/.*\\.fb2.zip" />
<data android:pathPattern="/.*\\.mobi" />
<data android:pathPattern="/.*\\.azw" />
<data android:pathPattern="/.*\\.cbz" />
<data android:pathPattern="/.*\\.zip" />
<data android:pathPattern="/.*\\.txt" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.epub" />
<data android:pathPattern=".*\\.pdf" />
<data android:pathPattern=".*\\.fb2" />
<data android:pathPattern=".*\\.fb2.zip" />
<data android:pathPattern=".*\\.mobi" />
<data android:pathPattern=".*\\.azw" />
<data android:pathPattern=".*\\.azw3" />
<data android:pathPattern=".*\\.cbz" />
<data android:pathPattern=".*\\.zip" />
<data android:pathPattern=".*\\.txt" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/>
</intent-filter>
<intent-filter android:label="oauth">
@@ -59,6 +87,7 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="readest" android:host="auth-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,8 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="#FF323130" />
</item>
<item android:gravity="center" android:width="120dp" android:height="120dp">
<bitmap android:src="@drawable/splash_icon" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

@@ -2,9 +2,7 @@
<!-- Base application theme. -->
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowSplashScreenBackground">@android:color/transparent</item>
<item name="android:windowSplashScreenAnimatedIcon" tools:targetApi="31">@null</item>
<item name="android:windowBackground">@drawable/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentStatus">false</item>
@@ -198,6 +198,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@Suppress("DEPRECATION")
window.setDecorFitsSystemWindows(false)
val controller = window.insetsController
if (controller != null) {
@@ -228,7 +229,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val compatController = WindowCompat.getInsetsController(window, decorView)
compatController?.let {
compatController.let {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (!isDarkMode) {
it.isAppearanceLightStatusBars = true
@@ -259,7 +260,9 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
}.reduce { acc, flag -> acc or flag }
}
@Suppress("DEPRECATION")
window.statusBarColor = Color.TRANSPARENT
@Suppress("DEPRECATION")
window.navigationBarColor = Color.TRANSPARENT
ret.put("success", true)
} catch (e: Exception) {
@@ -366,4 +369,37 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve()
}
@Command
fun get_safe_area_insets(invoke: Invoke) {
val ret = JSObject()
try {
val rootView = activity.findViewById<View>(android.R.id.content)
val windowInsets = androidx.core.view.ViewCompat.getRootWindowInsets(rootView)
if (windowInsets != null) {
val insets = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars() or
WindowInsetsCompat.Type.displayCutout()
)
val density = activity.resources.displayMetrics.density
ret.put("top", insets.top / density)
ret.put("right", insets.right / density)
ret.put("bottom", insets.bottom / density)
ret.put("left", insets.left / density)
} else {
ret.put("top", 0)
ret.put("right", 0)
ret.put("bottom", 0)
ret.put("left", 0)
}
} catch (e: Exception) {
ret.put("error", e.message)
ret.put("top", 0)
ret.put("right", 0)
ret.put("bottom", 0)
ret.put("left", 0)
}
invoke.resolve(ret)
}
}
@@ -14,6 +14,7 @@ const COMMANDS: &[&str] = &[
"iap_purchase_product",
"iap_restore_purchases",
"get_system_color_scheme",
"get_safe_area_insets",
];
fn main() {
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-safe-area-insets"
description = "Enables the get_safe_area_insets command without any pre-configured scope."
commands.allow = ["get_safe_area_insets"]
[[permission]]
identifier = "deny-get-safe-area-insets"
description = "Denies the get_safe_area_insets command without any pre-configured scope."
commands.deny = ["get_safe_area_insets"]
@@ -19,6 +19,7 @@ Default permissions for the plugin
- `allow-iap-purchase-product`
- `allow-iap-restore-purchases`
- `allow-get-system-color-scheme`
- `allow-get-safe-area-insets`
## Permission Table
@@ -110,6 +111,32 @@ Denies the copy_uri_to_path command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-get-safe-area-insets`
</td>
<td>
Enables the get_safe_area_insets command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-safe-area-insets`
</td>
<td>
Denies the get_safe_area_insets command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-get-status-bar-height`
</td>
@@ -16,4 +16,5 @@ permissions = [
"allow-iap-purchase-product",
"allow-iap-restore-purchases",
"allow-get-system-color-scheme",
"allow-get-safe-area-insets",
]
@@ -330,6 +330,18 @@
"const": "deny-copy-uri-to-path",
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Enables the get_safe_area_insets command without any pre-configured scope.",
"type": "string",
"const": "allow-get-safe-area-insets",
"markdownDescription": "Enables the get_safe_area_insets command without any pre-configured scope."
},
{
"description": "Denies the get_safe_area_insets command without any pre-configured scope.",
"type": "string",
"const": "deny-get-safe-area-insets",
"markdownDescription": "Denies the get_safe_area_insets command without any pre-configured scope."
},
{
"description": "Enables the get_status_bar_height command without any pre-configured scope.",
"type": "string",
@@ -475,10 +487,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`"
}
]
}
@@ -119,3 +119,10 @@ pub(crate) async fn get_system_color_scheme<R: Runtime>(
) -> Result<GetSystemColorSchemeResponse> {
app.native_bridge().get_system_color_scheme()
}
#[command]
pub(crate) async fn get_safe_area_insets<R: Runtime>(
app: AppHandle<R>,
) -> Result<GetSafeAreaInsetsResponse> {
app.native_bridge().get_safe_area_insets()
}
@@ -102,4 +102,8 @@ impl<R: Runtime> NativeBridge<R> {
pub fn get_system_color_scheme(&self) -> crate::Result<GetSystemColorSchemeResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -52,6 +52,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::iap_purchase_product,
commands::iap_restore_purchases,
commands::get_system_color_scheme,
commands::get_safe_area_insets,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -161,3 +161,11 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
self.0
.run_mobile_plugin("get_safe_area_insets", ())
.map_err(Into::into)
}
}
@@ -158,3 +158,12 @@ pub struct IAPRestorePurchasesResponse {
pub struct GetSystemColorSchemeResponse {
pub color_scheme: String, // "light" or "dark"
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSafeAreaInsetsResponse {
pub top: f64,
pub bottom: f64,
pub left: f64,
pub right: f64,
}
+4 -1
View File
@@ -6,6 +6,7 @@ extern crate cocoa;
#[macro_use]
extern crate objc;
use tauri::utils::config::BackgroundThrottlingPolicy;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -240,7 +241,9 @@ pub fn run() {
eprintln!("Failed to initialize tauri_plugin_log: {e}");
};
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.background_throttling(BackgroundThrottlingPolicy::Disabled)
.background_color(tauri::window::Color(50, 49, 48, 255));
#[cfg(desktop)]
let win_builder = win_builder.inner_size(800.0, 600.0).resizable(true);
+8 -1
View File
@@ -86,11 +86,18 @@
},
{
"name": "azw",
"ext": ["azw", "azw3"],
"ext": ["azw"],
"description": "AZW file",
"mimeType": "application/vnd.amazon.ebook",
"role": "Viewer"
},
{
"name": "azw3",
"ext": ["azw3"],
"description": "AZW3 file",
"mimeType": "application/vnd.amazon.mobi8-ebook",
"role": "Viewer"
},
{
"name": "fb2",
"ext": ["fb2"],
+5 -5
View File
@@ -67,7 +67,7 @@ export default function AuthPage() {
const router = useRouter();
const { login } = useAuth();
const { envConfig, appService } = useEnv();
const { isDarkMode } = useThemeStore();
const { isDarkMode, safeAreaInsets } = useThemeStore();
const { isTrafficLightVisible } = useTrafficLightStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [port, setPort] = useState<number | null>(null);
@@ -351,10 +351,10 @@ export default function AuthPage() {
)}
>
<div
className={clsx(
'flex h-full w-full flex-col items-center overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
className={clsx('flex h-full w-full flex-col items-center overflow-y-auto')}
style={{
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<div
ref={headerRef}
+11 -1
View File
@@ -37,7 +37,6 @@ export const viewport = {
maximumScale: 1,
userScalable: false,
viewportFit: 'cover',
themeColor: 'white',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -45,6 +44,17 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html>
<head>
<title>{title}</title>
<script
dangerouslySetInnerHTML={{
__html: `
const themeMode = localStorage.getItem('themeMode');
const themeColor = localStorage.getItem('themeColor');
if (themeMode && themeColor) {
document.documentElement.setAttribute('data-theme', \`\${themeColor}-\${themeMode}\`);
}
`,
}}
/>
<meta
name='viewport'
content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover'
@@ -8,6 +8,7 @@ import { PiPlus } from 'react-icons/pi';
import { Book, BooksGroup } from '@/types/book';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -53,6 +54,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const searchParams = useSearchParams();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
@@ -346,7 +348,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<Spinner loading />
</div>
)}
<div className='fixed bottom-0 left-0 right-0 z-40 pb-[calc(env(safe-area-inset-bottom)+16px)]'>
<div
className='fixed bottom-0 left-0 right-0 z-40'
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
{isSelectMode && showSelectModeActions && (
<div
className={clsx(
@@ -425,10 +432,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
)}
{showDeleteAlert && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
<Alert
title={_('Confirm Deletion')}
@@ -9,7 +9,6 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useLongPress } from '@/hooks/useLongPress';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getLocalBookFilename } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
@@ -192,7 +191,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getLocalBookFilename(book)}`;
const folder = `${settings.localBooksDir}/${book.hash}`;
revealItemInDir(folder);
},
});
@@ -13,7 +13,6 @@ import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { debounce } from '@/utils/debounce';
@@ -59,7 +58,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const headerRef = useRef<HTMLDivElement>(null);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
const insets = useSafeAreaInsets();
const { safeAreaInsets: insets } = useThemeStore();
useShortcuts({
onToggleSelectMode,
@@ -95,7 +94,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService?.hasTrafficLight]);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
@@ -8,7 +8,6 @@ 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';
@@ -159,11 +158,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsTelemetryEnabled(settings.telemetryEnabled);
};
const showKoSyncSettingsWindow = () => {
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handleUpgrade = () => {
navigateToProfile(router);
setIsDropdownOpen?.(false);
@@ -274,8 +268,6 @@ 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} />
)}
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useSync } from '@/hooks/useSync';
import { useLibraryStore } from '@/store/libraryStore';
import { Book } from '@/types/book';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { debounce } from '@/utils/debounce';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
export interface UseBooksSyncProps {
onSyncStart?: () => void;
@@ -17,12 +17,11 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
const { appService } = useEnv();
const { library, setLibrary } = useLibraryStore();
const { syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
const syncBooksPullingRef = useRef(false);
const pullLibrary = async () => {
const pullLibrary = useCallback(async () => {
if (!user) return;
syncBooks([], 'pull');
};
await syncBooks([], 'pull');
}, [user, syncBooks]);
const pushLibrary = async () => {
if (!user) return;
@@ -32,15 +31,13 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
useEffect(() => {
if (!user) return;
if (syncBooksPullingRef.current) return;
syncBooksPullingRef.current = true;
pullLibrary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [user]);
const getNewBooks = () => {
if (!user) return [];
const library = useLibraryStore.getState().library;
const newBooks = library.filter(
(book) => lastSyncedAtBooks < book.updatedAt || lastSyncedAtBooks < (book.deletedAt ?? 0),
);
@@ -53,14 +50,14 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
const newBooks = getNewBooks();
syncBooks(newBooks, 'both');
}, SYNC_BOOKS_INTERVAL_SEC * 1000),
[library, lastSyncedAtBooks],
[syncBooks],
);
useEffect(() => {
if (!user) return;
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [library]);
}, [library, handleAutoSync]);
const updateLibrary = async () => {
if (!syncedBooks?.length) return;
+27 -61
View File
@@ -18,12 +18,13 @@ import { getFilename } from '@/utils/path';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
import { BOOK_ACCEPT_FORMATS } from '@/services/constants';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -32,9 +33,9 @@ import { useTheme } from '@/hooks/useTheme';
import { useUICSS } from '@/hooks/useUICSS';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation } from '@/utils/bridge';
import {
tauriHandleSetAlwaysOnTop,
@@ -43,7 +44,6 @@ 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';
@@ -73,7 +73,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setCheckLastOpenBooks,
} = useLibraryStore();
const _ = useTranslation();
const insets = useSafeAreaInsets();
const { selectFiles } = useFileSelector(appService, _);
const { safeAreaInsets: insets } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
@@ -164,7 +165,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
impactFeedback('medium');
}
await importBooks(supportedFiles);
const selectedFiles = supportedFiles.map(
(file) =>
({
file: typeof file === 'string' ? undefined : file,
path: typeof file === 'string' ? file : undefined,
}) as SelectedFile,
);
await importBooks(selectedFiles);
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
@@ -392,7 +400,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [demoBooks, libraryLoaded]);
const importBooks = async (files: (string | File)[]) => {
const importBooks = async (files: SelectedFile[]) => {
setLoading(true);
const failedFiles = [];
const errorMap: [string, string][] = [
@@ -401,7 +409,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
['Unsupported format.', _('This book format is not supported.')],
];
const { library } = useLibraryStore.getState();
for (const file of files) {
for (const selectedFile of files) {
const file = selectedFile.file || selectedFile.path;
if (!file) continue;
try {
const book = await appService?.importBook(file, library);
setLibrary([...library]);
@@ -431,32 +441,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setLoading(false);
};
const selectFilesTauri = async () => {
const exts = appService?.isIOSApp ? [] : SUPPORTED_BOOK_EXTS;
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
if (appService?.isIOSApp) {
return files.filter((file) => {
const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown';
return SUPPORTED_BOOK_EXTS.includes(fileExt);
});
}
return files;
};
const selectFilesWeb = () => {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = BOOK_ACCEPT_FORMATS;
fileInput.multiple = true;
fileInput.click();
fileInput.onchange = () => {
resolve(fileInput.files);
};
});
};
const updateBookTransferProgress = throttle((bookHash: string, progress: ProgressPayload) => {
if (progress.total === 0) return;
const progressPct = (progress.progress / progress.total) * 100;
@@ -605,14 +589,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const handleImportBooks = async () => {
setIsSelectMode(false);
console.log('Importing books...');
let files;
if (isTauriAppPlatform()) {
files = (await selectFilesTauri()) as string[];
} else {
files = (await selectFilesWeb()) as File[];
}
importBooks(files);
selectFiles({ type: 'books', multiple: true }).then((result) => {
if (result.files.length === 0 || result.error) return;
importBooks(result.files);
});
};
const handleSetSelectMode = (selectMode: boolean) => {
@@ -638,19 +618,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setShowDetailsBook(book);
};
if (!appService || !insets) {
return null;
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
return <div className='bg-base-200 h-[100vh]' />;
}
if (checkOpenWithBooks || checkLastOpenBooks) {
return (
loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)
);
}
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
return (
<div
@@ -677,11 +649,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<Spinner loading />
</div>
)}
{libraryLoaded &&
{showBookshelf &&
(libraryBooks.some((book) => !book.deletedAt) ? (
<OverlayScrollbarsComponent
defer
ref={osRef}
className='flex-grow'
options={{ scrollbars: { autoHide: 'scroll' } }}
events={{
initialized: (instance) => {
@@ -749,7 +722,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
/>
)}
<AboutWindow />
<KOSyncSettingsWindow />
<UpdaterWindow />
<Toast />
</div>
@@ -758,13 +730,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const LibraryPage = () => {
return (
<Suspense
fallback={
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
}
>
<Suspense fallback={<div className='bg-base-200 h-[100vh]' />}>
<LibraryPageWithSearchParams />
</Suspense>
);
+2 -9
View File
@@ -1,14 +1,7 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import LibraryPage from './library/page';
export default function HomePage() {
const router = useRouter();
useEffect(() => {
router.replace('/library');
}, [router]);
return null;
return <LibraryPage />;
}
@@ -2,11 +2,11 @@ import clsx from 'clsx';
import React, { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import FoliateViewer from './FoliateViewer';
@@ -34,7 +34,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const screenInsets = useSafeAreaInsets();
const { safeAreaInsets: screenInsets } = useThemeStore();
const aspectRatio = window.innerWidth / window.innerHeight;
const gridTemplate = getGridTemplate(bookKeys.length, aspectRatio);
@@ -45,6 +45,7 @@ import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
import Spinner from '@/components/Spinner';
import ConfirmSyncDialog from './ConfirmSyncDialog';
declare global {
@@ -74,6 +75,8 @@ const FoliateViewer: React.FC<{
const isViewCreated = useRef(false);
const doubleClickDisabled = useRef(!!viewSettings?.disableDoubleClick);
const [toastMessage, setToastMessage] = useState('');
const [loading, setLoading] = useState(false);
const docLoaded = useRef(false);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
@@ -127,6 +130,8 @@ const FoliateViewer: React.FC<{
};
const docLoadHandler = (event: Event) => {
setLoading(false);
docLoaded.current = true;
const detail = (event as CustomEvent).detail;
console.log('doc index loaded:', detail.index);
if (detail.doc) {
@@ -228,6 +233,8 @@ const FoliateViewer: React.FC<{
if (isViewCreated.current) return;
isViewCreated.current = true;
setTimeout(() => setLoading(true), 200);
const openBook = async () => {
console.log('Opening book', bookKey);
await import('foliate-js/view.js');
@@ -383,6 +390,7 @@ const FoliateViewer: React.FC<{
{...mouseHandlers}
{...touchHandlers}
/>
{!docLoaded.current && loading && <Spinner loading={true} />}
{syncState === 'conflict' && conflictDetails && (
<ConfirmSyncDialog
details={conflictDetails}
@@ -1,17 +1,16 @@
import clsx from 'clsx';
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 { useEnv } from '@/context/EnvContext';
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 { KOSyncChecksumMethod, KOSyncStrategy } from '@/types/settings';
import { debounce } from '@/utils/debounce';
import { getOSPlatform } from '@/utils/misc';
import Dialog from '@/components/Dialog';
type Option = {
value: string;
@@ -69,19 +68,14 @@ export const KOSyncSettingsWindow: React.FC = () => {
const { envConfig, appService } = useEnv();
const [isOpen, setIsOpen] = useState(false);
const [url, setUrl] = useState(settings.koreaderSyncServerUrl || '');
const [username, setUsername] = useState(settings.koreaderSyncUsername || '');
const [url, setUrl] = useState(settings.kosync.serverUrl || '');
const [username, setUsername] = useState(settings.kosync.username || '');
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 formatOsName = (name: string): string => {
@@ -108,13 +102,10 @@ export const KOSyncSettingsWindow: React.FC = () => {
useEffect(() => {
const defaultName = osName ? `Readest (${osName})` : 'Readest';
setDeviceName(settings.koreaderSyncDeviceName || defaultName);
}, [settings.koreaderSyncDeviceName, osName]);
setDeviceName(settings.kosync.deviceName || defaultName);
}, [settings.kosync.deviceName, osName]);
const isConfigured = useMemo(
() => !!settings.koreaderSyncUserkey,
[settings.koreaderSyncUserkey],
);
const isConfigured = useMemo(() => !!settings.kosync.userkey, [settings.kosync.userkey]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSaveDeviceName = useCallback(
@@ -136,15 +127,10 @@ export const KOSyncSettingsWindow: React.FC = () => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
setUrl(settings.koreaderSyncServerUrl || '');
setUsername(settings.koreaderSyncUsername || '');
setUrl(settings.kosync.serverUrl || '');
setUsername(settings.kosync.username || '');
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');
@@ -152,40 +138,26 @@ export const KOSyncSettingsWindow: React.FC = () => {
return () => {
el?.removeEventListener('setKOSyncSettingsVisibility', handleCustomEvent as EventListener);
};
}, [
settings.koreaderSyncServerUrl,
settings.koreaderSyncUsername,
settings.koreaderSyncPercentageTolerance,
]);
}, [settings.kosync.serverUrl, settings.kosync.username]);
const handleConnect = async () => {
setIsConnecting(true);
let deviceId = settings.koreaderSyncDeviceId;
if (!deviceId) {
deviceId = uuidv4().replace(/-/g, '').toUpperCase();
}
const client = new KOSyncClient(
url,
const config = {
...settings.kosync,
serverUrl: url,
username,
md5(password),
settings.koreaderSyncChecksumMethod,
deviceId,
userkey: md5(password),
deviceName,
);
enabled: true,
};
const client = new KOSyncClient(config);
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,
kosync: config,
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
@@ -201,11 +173,12 @@ export const KOSyncSettingsWindow: React.FC = () => {
};
const handleDisconnect = async () => {
const newSettings = {
...settings,
koreaderSyncStrategy: 'disabled' as KoreaderSyncStrategy,
koreaderSyncUserkey: '',
const kosync = {
...settings.kosync,
userkey: '',
enabled: false,
};
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
setUsername('');
@@ -213,26 +186,23 @@ export const KOSyncSettingsWindow: React.FC = () => {
};
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newStrategy = e.target.value as KoreaderSyncStrategy;
const newSettings = { ...settings, koreaderSyncStrategy: newStrategy };
const kosync = {
...settings.kosync,
strategy: e.target.value as KOSyncStrategy,
};
const newSettings = { ...settings, kosync };
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 kosync = {
...settings.kosync,
checksumMethod: e.target.value as KOSyncChecksumMethod,
};
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 };
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
@@ -251,18 +221,16 @@ export const KOSyncSettingsWindow: React.FC = () => {
<div className='text-center'>
<p className='text-base-content/80 text-sm'>
{_('Sync as {{userDisplayName}}', {
userDisplayName: settings.koreaderSyncUsername,
userDisplayName: settings.kosync.username,
})}
</p>
</div>
<div className='flex h-14 items-center justify-between'>
<span className='text-base-content/80'>
{_('Sync Server Connected', { username: settings.koreaderSyncUsername })}
</span>
<span className='text-base-content/80'>{_('Sync Server Connected')}</span>
<input
type='checkbox'
className='toggle'
checked={settings.koreaderSyncStrategy !== 'disabled'}
checked={settings.kosync.enabled}
onChange={() => handleDisconnect()}
/>
</div>
@@ -271,14 +239,13 @@ export const KOSyncSettingsWindow: React.FC = () => {
<span className='label-text font-medium'>{_('Sync Strategy')}</span>
</label>
<StyledSelect
value={settings.koreaderSyncStrategy}
value={settings.kosync.strategy}
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>
@@ -287,7 +254,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
<span className='label-text font-medium'>{_('Checksum Method')}</span>
</label>
<StyledSelect
value={settings.koreaderSyncChecksumMethod}
value={settings.kosync.checksumMethod}
onChange={handleChecksumMethodChange}
options={[
{ value: 'binary', label: _('File Content (recommended)') },
@@ -307,27 +274,6 @@ export const KOSyncSettingsWindow: React.FC = () => {
onChange={handleDeviceNameChange}
/>
</div>
{/* Hidden to avoid confusing users with technical details */}
{false && (
<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}} digits after the decimal', {
precision: toleranceSliderValue,
})}
</div>
</div>
)}
</>
) : (
<>
@@ -21,6 +21,7 @@ import { isTauriAppPlatform } from '@/services/environment';
import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { KOSyncSettingsWindow } from './KOSyncSettings';
import { Toast } from '@/components/Toast';
import { getLocale } from '@/utils/misc';
import { initDayjs } from '@/utils/time';
@@ -106,24 +107,25 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey]);
return (
libraryLoaded &&
settings.globalReadSettings && (
<div
className={clsx(
`reader-page text-base-content select-none overflow-hidden`,
appService?.isLinuxApp && 'window-border',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<UpdaterWindow />
<Toast />
</Suspense>
</div>
)
return libraryLoaded && settings.globalReadSettings ? (
<div
className={clsx(
`reader-page bg-base-100 text-base-content select-none overflow-hidden`,
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
appService?.isLinuxApp && 'window-border',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<Suspense fallback={<div className='h-[100vh]'></div>}>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<UpdaterWindow />
<KOSyncSettingsWindow />
<Toast />
</Suspense>
</div>
) : (
<div className='bg-base-100 h-[100vh]'></div>
);
};
@@ -111,7 +111,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (isPrimary && book && config) {
const settings = useSettingsStore.getState().settings;
eventDispatcher.dispatch('sync-book-progress', { bookKey });
eventDispatcher.dispatch('flush-koreader-sync', { bookKey });
eventDispatcher.dispatch('flush-kosync', { bookKey });
await saveConfig(envConfig, bookKey, config, settings);
}
};
@@ -142,7 +142,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const handleCloseBooksToLibrary = () => {
handleCloseBooks();
navigateToLibrary(router);
if (isTauriAppPlatform()) {
const currentWindow = getCurrentWindow();
if (currentWindow.label === 'main') {
navigateToLibrary(router);
} else {
currentWindow.close();
}
} else {
navigateToLibrary(router);
}
};
const handleCloseBook = async (bookKey: string) => {
@@ -170,7 +179,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const bookData = getBookData(bookKeys[0]!);
const viewSettings = getViewSettings(bookKeys[0]!);
if (!bookData || !bookData.book || !bookData.bookDoc || !viewSettings) {
setTimeout(() => setLoading(true), 300);
setTimeout(() => setLoading(true), 200);
return (
loading && (
<div className={clsx('hero hero-content', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
@@ -1,17 +1,20 @@
import clsx from 'clsx';
import React from 'react';
import { useThemeStore } from '@/store/themeStore';
interface RibbonProps {
width: string;
}
const Ribbon: React.FC<RibbonProps> = ({}) => {
const { safeAreaInsets } = useThemeStore();
return (
<div
className={clsx(
'ribbon absolute inset-0 z-10 flex w-8 justify-center sm:w-6',
'h-[calc(env(safe-area-inset-top)+44px)]',
)}
className={clsx('ribbon absolute inset-0 z-10 flex w-8 justify-center sm:w-6')}
style={{
height: `${(safeAreaInsets?.top || 0) + 44}px`,
}}
>
<svg
width='100%'
@@ -25,7 +25,7 @@ const MAX_NOTEBOOK_WIDTH = 0.45;
const Notebook: React.FC = ({}) => {
const _ = useTranslation();
const { updateAppTheme } = useThemeStore();
const { updateAppTheme, safeAreaInsets } = useThemeStore();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
@@ -196,7 +196,6 @@ const Notebook: React.FC = ({}) => {
'notebook-container bg-base-200 right-0 z-20 flex min-w-60 select-none flex-col',
'font-sans text-base font-normal sm:text-sm',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
!isNotebookPinned && 'shadow-2xl',
)}
@@ -205,6 +204,7 @@ const Notebook: React.FC = ({}) => {
width: `${notebookWidth}`,
maxWidth: `${MAX_NOTEBOOK_WIDTH * 100}%`,
position: isNotebookPinned ? 'relative' : 'absolute',
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<style jsx>{`
@@ -6,9 +6,9 @@ import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useCustomFontStore } from '@/store/customFontStore';
import { FILE_SELECTION_PRESETS, useFileSelector } from '@/hooks/useFileSelector';
import { useFileSelector } from '@/hooks/useFileSelector';
import { mountCustomFont } from '@/styles/fonts';
import { parseFontFamily } from '@/utils/font';
import { parseFontName } from '@/utils/font';
import { getFilename } from '@/utils/path';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
@@ -41,7 +41,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
currentDefaultFont === 'serif' ? viewSettings.serifFont : viewSettings.sansSerifFont;
const handleImportFont = () => {
selectFiles({ ...FILE_SELECTION_PRESETS.fonts, multiple: true }).then(async (result) => {
selectFiles({ type: 'fonts', multiple: true }).then(async (result) => {
if (result.error || result.files.length === 0) return;
if (!(await appService!.fs.exists('', 'Fonts'))) {
await appService!.fs.createDir('', 'Fonts');
@@ -62,9 +62,11 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
} else {
continue;
}
const fontFamily = parseFontFamily(await fontFile.arrayBuffer(), fontPath);
const fontName = parseFontName(await fontFile.arrayBuffer(), fontPath);
const customFont = addFont(fontPath, {
name: fontFamily,
name: fontName.name,
family: fontName.family,
style: fontName.style,
});
if (customFont && !customFont.error) {
const loadedFont = await loadFont(envConfig, customFont.id);
@@ -148,7 +150,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
<div className='flex items-center justify-center'>
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
</div>
<div className='text-primary/85 group-hover:text-primary font-medium'>
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
{_('Import Font')}
</div>
</div>
@@ -162,9 +164,10 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
'card h-12 border shadow-sm',
currentFontFamily === font.name
? 'border-primary/50 bg-primary/50'
: 'border-base-200 bg-base-200 cursor-pointer',
: `border-base-200 bg-base-200 ${isDeleteMode ? '' : 'cursor-pointer'}`,
)}
onClick={() => handleSelectFont(font.id)}
onClick={!isDeleteMode ? () => handleSelectFont(font.id) : undefined}
title={font.name}
>
<div className='card-body flex items-center justify-center p-2'>
<div
@@ -172,9 +175,9 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
fontFamily: font.loaded ? `"${font.name}", sans-serif` : 'sans-serif',
fontWeight: 400,
}}
className='text-base-content line-clamp-1'
className='text-base-content line-clamp-1 break-all'
>
{font.name}
{font.family || font.name}
</div>
{isDeleteMode && (
<button
@@ -194,7 +197,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
<div className='text-base-content/70 text-sm sm:text-xs'>
<div className='mb-1 indent-2 font-medium'>{_('Tips')}:</div>
<ul className='list-outside list-disc space-y-1 ps-2'>
<li>{_('Supported font formats: .ttf, .odf, .woff, .woff2')}</li>
<li>{_('Supported font formats: .ttf, .otf, .woff, .woff2')}</li>
<li>{_('Custom fonts can be selected from the Font Face menu')}</li>
</ul>
</div>
@@ -41,7 +41,11 @@ const FontItem: React.FC<FontItemProps> = ({ index, style, data }) => {
<MdCheck className='text-base-content' size={iconSize16} />
)}
</span>
<span style={{ fontFamily: onGetFontFamily(option.option, family) }}>
<span
className='line-clamp-1 overflow-visible break-all leading-loose'
style={{ fontFamily: onGetFontFamily(option.option, family) }}
title={option.label || option.option}
>
{option.label || option.option}
</span>
</div>
@@ -100,7 +104,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
>
<div className='flex items-center gap-x-1'>
<span
className='text-ellipsis'
className='line-clamp-1 overflow-visible break-all leading-loose'
style={{
fontFamily: onGetFontFamily(selectedOption.option, family ?? ''),
}}
@@ -89,10 +89,16 @@ const FontFace = ({
const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const { fontPanelView, setFontPanelView } = useSettingsStore();
const { fonts: allCustomFonts, getFontFamilies } = useCustomFontStore();
const {
fonts: allCustomFonts,
getAllFonts,
getFontFamilies,
removeFont,
saveCustomFonts,
} = useCustomFontStore();
const viewSettings = getViewSettings(bookKey)!;
const view = getView(bookKey)!;
const iconSize18 = useResponsiveSize(18);
@@ -159,6 +165,12 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
monospaceFont: setMonospaceFont,
fontWeight: setFontWeight,
});
getAllFonts().forEach((font) => {
if (removeFont(font.id)) {
appService!.fs.removeFile(font.path, 'Fonts');
}
});
saveCustomFonts(envConfig);
};
const handleManageCustomFonts = () => {
@@ -172,7 +184,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
useEffect(() => {
onRegisterReset(handleReset);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService]);
useEffect(() => {
setCJKFonts((prev) => {
@@ -157,7 +157,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, []);
}, [setFontPanelView]);
const currentPanel = tabConfig.find((tab) => tab.tab === activePanel);
@@ -8,10 +8,12 @@ import { useReaderStore } from '@/store/readerStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
import useBooksManager from '../../hooks/useBooksManager';
import MenuItem from '@/components/MenuItem';
@@ -22,6 +24,7 @@ interface BookMenuProps {
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { bookKeys, getViewSettings, setViewSettings } = useReaderStore();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
@@ -69,6 +72,18 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
unsetParallel(bookKeys);
setIsDropdownOpen?.(false);
};
const showKoSyncSettingsWindow = () => {
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handlePullKOSync = () => {
eventDispatcher.dispatch('pull-kosync', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const handlePushKOSync = () => {
eventDispatcher.dispatch('push-kosync', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const isWebApp = isWebAppPlatform();
@@ -118,6 +133,14 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
</ul>
</MenuItem>
<hr className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
{settings.kosync.enabled && (
<>
<MenuItem label={_('Push Progress')} onClick={handlePushKOSync} />
<MenuItem label={_('Pull Progress')} onClick={handlePullKOSync} />
</>
)}
<hr className='border-base-200 my-1' />
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
<MenuItem
label={_('Sort TOC by Page')}
@@ -2,7 +2,7 @@ import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
@@ -15,7 +15,7 @@ const SidebarContent: React.FC<{
bookDoc: BookDoc;
sideBarBookKey: string;
}> = ({ bookDoc, sideBarBookKey }) => {
const { appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const { getConfig, setConfig } = useBookDataStore();
const config = getConfig(sideBarBookKey);
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
@@ -75,10 +75,10 @@ const SidebarContent: React.FC<{
</OverlayScrollbarsComponent>
</div>
<div
className={clsx(
'flex-shrink-0',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom)/2)]',
)}
className='flex-shrink-0'
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) / 2}px`,
}}
>
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
</div>
@@ -1,10 +1,10 @@
import clsx from 'clsx';
import React from 'react';
import React, { useEffect } from 'react';
import { GiBookshelf } from 'react-icons/gi';
import { FiSearch } from 'react-icons/fi';
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import Dropdown from '@/components/Dropdown';
@@ -18,11 +18,30 @@ const SidebarHeader: React.FC<{
onTogglePin: () => void;
onToggleSearchBar: () => void;
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
const { isTrafficLightVisible } = useTrafficLightStore();
const { appService } = useEnv();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
initializeTrafficLightListeners,
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const iconSize14 = useResponsiveSize(14);
const iconSize18 = useResponsiveSize(18);
const iconSize22 = useResponsiveSize(22);
useEffect(() => {
if (!appService?.hasTrafficLight) return;
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.hasTrafficLight]);
return (
<div
className={clsx(
@@ -29,7 +29,7 @@ const SideBar: React.FC<{
onGoToLibrary: () => void;
}> = ({ onGoToLibrary }) => {
const { appService } = useEnv();
const { updateAppTheme } = useThemeStore();
const { updateAppTheme, safeAreaInsets } = useThemeStore();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { getBookData } = useBookDataStore();
@@ -186,7 +186,6 @@ const SideBar: React.FC<{
'sidebar-container bg-base-200 z-20 flex min-w-60 select-none flex-col',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
'transition-[padding-top] duration-300',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
!isSideBarPinned && 'shadow-2xl',
)}
@@ -195,6 +194,7 @@ const SideBar: React.FC<{
width: `${sideBarWidth}`,
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
position: isSideBarPinned ? 'relative' : 'absolute',
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<style jsx>{`
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -27,6 +28,7 @@ interface TTSControlProps {
const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const { getBookData } = useBookDataStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
@@ -429,10 +431,13 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
className={clsx(
'absolute h-12 w-12',
viewSettings?.rtl ? 'left-6' : 'right-6',
appService?.hasSafeAreaInset
? 'bottom-[calc(env(safe-area-inset-bottom)+70px)]'
: 'bottom-[70px] sm:bottom-14',
!appService?.hasSafeAreaInset && 'bottom-[70px] sm:bottom-14',
)}
style={{
bottom: appService?.hasSafeAreaInset
? `${(safeAreaInsets?.bottom || 0) + 70}px`
: undefined,
}}
>
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
</div>
+209 -372
View File
@@ -1,17 +1,15 @@
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, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { Book, BookProgress, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { getCFIFromXPointer, XCFI } from '@/utils/xcfi';
import { getCFIFromXPointer, normalizeProgressXPointer, XCFI } from '@/utils/xcfi';
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
@@ -30,386 +28,262 @@ export interface SyncDetails {
export const useKOSync = (bookKey: string) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { getProgress, getView } = useReaderStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const progress = getProgress(bookKey);
const [kosyncClient, setKOSyncClient] = useState<KOSyncClient | null>(null);
const [syncState, setSyncState] = useState<SyncState>('idle');
const [conflictDetails, setConflictDetails] = useState<SyncDetails | null>(null);
const [errorMessage] = useState<string | null>(null);
const hasPulledOnce = useRef(false);
const syncCompletedForKey = useRef<string | null>(null);
const lastPushedCfiRef = useRef<string | null>(null);
const bookData = getBookData(bookKey);
const book = bookData?.book;
const bookDoc = bookData?.bookDoc;
const progress = getProgress(bookKey);
useEffect(() => {
lastPushedCfiRef.current = null;
syncCompletedForKey.current = null;
setSyncState('idle');
}, [bookKey]);
if (!settings.kosync.username || !settings.kosync.userkey) {
setKOSyncClient(null);
return;
}
const client = new KOSyncClient(settings.kosync);
setKOSyncClient(client);
}, [settings]);
const mapProgressToServerFormat = useCallback(() => {
const currentProgress = getProgress(bookKey);
const currentBook = getBookData(bookKey)?.book;
if (!currentProgress || !currentBook) return null;
const generateKOProgress = useCallback(() => {
const progress = getProgress(bookKey);
const book = getBookData(bookKey)?.book;
if (!progress || !book) return null;
let progressStr: string;
let koProgress = '';
let percentage: number;
if (FIXED_LAYOUT_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;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const page = progress.section?.current ?? 0;
const totalPages = progress.section?.total ?? 0;
koProgress = page.toString();
percentage = totalPages > 0 ? (page + 1) / 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 cfi = progress.location;
if (!view || !cfi) return null;
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(cfi);
koProgress = normalizeProgressXPointer(xpointerResult.xpointer);
}
} catch (error) {
console.error('Failed to convert CFI to XPointer', error);
}
const page = currentProgress.pageinfo?.current ?? 0;
const totalPages = currentProgress.pageinfo?.total ?? 0;
const page = progress.pageinfo?.current ?? 0;
const totalPages = progress.pageinfo?.total ?? 0;
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
}
return { progressStr, percentage };
return { koProgress, percentage };
}, [bookKey, getProgress, getBookData, getView]);
const applyRemoteProgress = async (book: Book, bookDoc: BookDoc, remote: KoSyncProgress) => {
const view = getView(bookKey);
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const pageToGo = parseInt(remote.progress!, 10);
if (isNaN(pageToGo)) return;
view?.select(pageToGo - 1);
} else {
if (!remote.progress?.startsWith('/body')) return;
try {
const content = view?.renderer.getContents()[0];
const cfi = await getCFIFromXPointer(
remote.progress!,
content?.doc,
content?.index,
bookDoc,
);
view?.goTo(cfi);
} catch (error) {
console.error('Failed to convert XPointer to CFI', error);
return;
}
}
eventDispatcher.dispatch('toast', { message: _('Reading Progress Synced'), type: 'info' });
};
const promptedSync = async (
book: Book,
bookDoc: BookDoc,
local: BookProgress,
remote: KoSyncProgress,
) => {
let localPreview = '';
let remotePreview = '';
const remotePercentage = remote.percentage || 0;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const localPageInfo = local.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 = local.pageinfo;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = `${local.sectionLabel} (${localPercentage}%)`;
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
setConflictDetails({
book,
bookDoc,
local: { cfi: local.location, preview: localPreview },
remote: { ...remote, preview: remotePreview },
});
};
const pushProgress = useMemo(
() =>
debounce(async () => {
const { settings: currentSettings } = useSettingsStore.getState();
if (!bookKey || !appService || !kosyncClient || !hasPulledOnce.current) return;
const { settings } = useSettingsStore.getState();
if (['receive', 'disable'].includes(settings.kosync.strategy)) return;
const currentBook = getBookData(bookKey)?.book;
const progress = generateKOProgress();
if (!currentBook || !progress || !progress.koProgress) return;
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;
await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);
}, 5000),
[bookKey, appService, getBookData, mapProgressToServerFormat],
// eslint-disable-next-line react-hooks/exhaustive-deps
[bookKey, appService, kosyncClient],
);
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]);
const pullProgress = useCallback(
async () => {
if (!progress?.location || !appService || !kosyncClient) return;
useEffect(() => {
const performInitialSync = async () => {
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = settings;
if (
!book ||
!bookDoc ||
!progress ||
!koreaderSyncUsername ||
!koreaderSyncUserkey ||
koreaderSyncStrategy === 'disabled'
)
return;
const bookData = getBookData(bookKey);
const book = bookData?.book;
const bookDoc = bookData?.bookDoc;
if (!book || !bookDoc) return;
if (koreaderSyncStrategy === 'send') {
syncCompletedForKey.current = bookKey;
const { strategy, enabled } = settings.kosync;
if (!enabled) return;
hasPulledOnce.current = true;
if (strategy === 'send') {
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;
const remoteProgress = await kosyncClient.getProgress(book);
if (!remoteProgress || !remoteProgress.progress || !remoteProgress.timestamp) {
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 = FIXED_LAYOUT_FORMATS.has(book.format)
? progress.section?.current.toString()
: progress.location;
const isLocalCFI = localIdentifier?.startsWith('epubcfi');
const remoteIdentifier = FIXED_LAYOUT_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;
const remoteIsNewer = remoteProgress.timestamp * 1000 > localTimestamp;
if (strategy === 'receive' || (strategy === 'silent' && remoteIsNewer)) {
applyRemoteProgress(book, bookDoc, remoteProgress);
setSyncState('synced');
return;
}
if (
settings.koreaderSyncStrategy === 'receive' ||
(settings.koreaderSyncStrategy === 'silent' && remoteIsNewer)
) {
const applyRemoteProgress = async () => {
const view = getView(bookKey);
if (view && remote.progress) {
if (FIXED_LAYOUT_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 (FIXED_LAYOUT_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 },
});
} else if (strategy === 'prompt') {
promptedSync(book, bookDoc, progress, remoteProgress);
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,
]);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[bookKey, appService, kosyncClient, settings.kosync, progress],
);
useEffect(() => {
const handlePushProgress = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pushProgress();
};
const handleFlush = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pushProgress.flush();
};
eventDispatcher.on('push-kosync', handlePushProgress);
eventDispatcher.on('flush-kosync', handleFlush);
return () => {
eventDispatcher.off('push-kosync', handlePushProgress);
eventDispatcher.off('flush-kosync', handleFlush);
pushProgress.flush();
};
}, [bookKey, pushProgress]);
useEffect(() => {
const handlePullProgress = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pullProgress();
};
eventDispatcher.on('pull-kosync', handlePullProgress);
return () => {
eventDispatcher.off('pull-kosync', handlePullProgress);
};
}, [bookKey, pullProgress]);
// Pull: pull progress once when the book is opened
useEffect(() => {
if (!appService || !kosyncClient || !progress?.location) return;
if (hasPulledOnce.current) return;
pullProgress();
}, [appService, kosyncClient, progress?.location, pushProgress, pullProgress]);
// Push: auto-push progress when progress changes with a debounce
useEffect(() => {
if (syncState === 'synced' && progress) {
if (
settings.koreaderSyncStrategy !== 'receive' &&
settings.koreaderSyncStrategy !== 'disabled'
) {
const { strategy, enabled } = settings.kosync;
if (strategy !== 'receive' && enabled) {
pushProgress();
}
}
}, [progress, syncState, settings.koreaderSyncStrategy, pushProgress]);
useEffect(() => {
return () => {
pushProgress.flush();
};
}, [pushProgress]);
}, [progress, syncState, settings.kosync, pushProgress]);
const resolveConflictWithLocal = () => {
pushProgress();
@@ -421,50 +295,12 @@ export const useKOSync = (bookKey: string) => {
const resolveConflictWithRemote = async () => {
const view = getView(bookKey);
const remote = conflictDetails?.remote;
const currentBook = conflictDetails?.book;
const book = conflictDetails?.book;
const bookDoc = conflictDetails?.bookDoc;
if (view && remote?.progress && currentBook) {
if (FIXED_LAYOUT_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 (!book || !bookDoc || !remote || !remote.progress || !view) return;
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' });
}
applyRemoteProgress(book, bookDoc, remote);
setSyncState('synced');
setConflictDetails(null);
};
@@ -473,8 +309,9 @@ export const useKOSync = (bookKey: string) => {
syncState,
conflictDetails,
errorMessage,
pushProgress,
pullProgress,
resolveConflictWithLocal,
resolveConflictWithRemote,
pushProgress,
};
};
@@ -33,14 +33,14 @@ export const useNotesSync = (bookKey: string) => {
const newNotes = getNewNotes();
syncNotes(newNotes, bookHash, 'both');
}, SYNC_NOTES_INTERVAL_SEC * 1000),
[lastSyncedAtNotes],
[syncNotes],
);
useEffect(() => {
if (!config?.location || !user) return;
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
}, [config, handleAutoSync]);
useEffect(() => {
const processNewNote = (note: BookNote) => {
@@ -11,7 +11,7 @@ import { CFI } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
import { getCFIFromXPointer, getXPointerFromCFI, normalizeProgressXPointer } from '@/utils/xcfi';
export const useProgressSync = (bookKey: string) => {
const _ = useTranslation();
@@ -56,7 +56,7 @@ export const useProgressSync = (bookKey: string) => {
if (content && !FIXED_LAYOUT_FORMATS.has(book.format)) {
const { doc, index } = content;
const xpointerResult = await getXPointerFromCFI(config.location!, doc, index || 0);
config.xpointer = xpointerResult.xpointer;
config.xpointer = normalizeProgressXPointer(xpointerResult.xpointer);
}
} catch (error) {
console.warn('Failed to convert CFI to XPointer', error);
+6 -4
View File
@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
@@ -58,6 +59,7 @@ const ProfilePage = () => {
const { envConfig, appService } = useEnv();
const { token, user, logout } = useAuth();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [availablePlans, setAvailablePlans] = useState<AvailablePlan[]>([]);
@@ -345,10 +347,10 @@ const ProfilePage = () => {
)}
>
<div
className={clsx(
'flex h-full w-full flex-col items-center overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
className={clsx('flex h-full w-full flex-col items-center overflow-y-auto')}
style={{
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<ProfileHeader onGoBack={handleGoBack} />
<div className='w-full min-w-60 max-w-4xl py-10'>
+2 -2
View File
@@ -41,7 +41,7 @@ const Dialog: React.FC<DialogProps> = ({
onClose,
}) => {
const { appService } = useEnv();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { systemUIVisible, statusBarHeight, safeAreaInsets } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(!snapHeight);
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
@@ -179,7 +179,7 @@ const Dialog: React.FC<DialogProps> = ({
style={{
paddingTop:
appService?.hasSafeAreaInset && isFullHeightInMobile
? `max(env(safe-area-inset-top), ${systemUIVisible ? statusBarHeight : 0}px)`
? `${Math.max(safeAreaInsets?.top || 0, systemUIVisible ? statusBarHeight : 0)}px`
: '0px',
...(isMobile ? { height: snapHeight ? `${snapHeight * 100}%` : '100%', bottom: 0 } : {}),
}}
@@ -11,7 +11,7 @@ const ModalPortal: React.FC<ModalPortalProps> = ({ children, showOverlay = true
return ReactDOM.createPortal(
<div
className={clsx(
'fixed inset-0 isolate z-50 flex items-center justify-center',
'fixed inset-0 isolate z-[100] flex items-center justify-center',
showOverlay && 'bg-black bg-opacity-50',
)}
style={{ transform: 'translateZ(0)' }}
@@ -6,12 +6,14 @@ import { AuthProvider } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { CSPostHogProvider } from '@/context/PHContext';
import { SyncProvider } from '@/context/SyncContext';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { initSystemThemeListener } from '@/store/themeStore';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { appService } = useEnv();
const iconSize = useDefaultIconSize();
useSafeAreaInsets(); // Initialize safe area insets
useEffect(() => {
if (appService) {
+6 -4
View File
@@ -1,19 +1,21 @@
import clsx from 'clsx';
import React from 'react';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
const Spinner: React.FC<{
loading: boolean;
}> = ({ loading }) => {
const _ = useTranslation();
const { safeAreaInsets } = useThemeStore();
if (!loading) return null;
return (
<div
className={clsx(
'absolute left-1/2 -translate-x-1/2 transform text-center',
'top-4 pt-[calc(env(safe-area-inset-top)+64px)]',
)}
className={clsx('absolute left-1/2 top-4 -translate-x-1/2 transform text-center')}
style={{
paddingTop: `${(safeAreaInsets?.top || 0) + 64}px`,
}}
role='status'
>
<span className='loading loading-dots loading-lg'></span>
+7 -2
View File
@@ -1,10 +1,12 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { useThemeStore } from '@/store/themeStore';
import { eventDispatcher } from '@/utils/event';
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export const Toast = () => {
const { safeAreaInsets } = useThemeStore();
const [toastMessage, setToastMessage] = useState('');
const toastType = useRef<ToastType>('info');
const toastTimeout = useRef(5000);
@@ -52,9 +54,12 @@ export const Toast = () => {
className={clsx(
'toast toast-center toast-middle z-50 w-auto max-w-screen-sm',
toastClassMap[toastType.current],
toastClassMap[toastType.current].includes('toast-top') &&
'top-[calc(44px+env(safe-area-inset-top))]',
)}
style={{
top: toastClassMap[toastType.current].includes('toast-top')
? `${(safeAreaInsets?.top || 0) + 44}px`
: undefined,
}}
>
<div
className={clsx(
@@ -7,9 +7,8 @@ import { BookMetadata } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { flattenContributors, formatAuthors, formatPublisher, formatTitle } from '@/utils/book';
import { useFileSelector } from '@/hooks/useFileSelector';
import { FormField } from './FormField';
import { IMAGE_ACCEPT_FORMATS, SUPPORTED_IMAGE_EXTS } from '@/services/constants';
import { isTauriAppPlatform } from '@/services/environment';
import BookCover from '@/components/BookCover';
interface BookDetailEditProps {
@@ -47,6 +46,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { selectFiles } = useFileSelector(appService, _);
const hasLockedFields = Object.values(lockedFields).some((locked) => locked);
const allFieldsLocked = Object.values(lockedFields).every((locked) => locked);
@@ -148,51 +148,23 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
},
];
const selectImageFileWeb = () => {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = IMAGE_ACCEPT_FORMATS;
fileInput.multiple = false;
fileInput.click();
fileInput.onchange = () => {
resolve(fileInput.files);
};
});
};
const selectImageFileTauri = async () => {
const exts = appService?.isMobileApp ? [] : SUPPORTED_IMAGE_EXTS;
const files = (await appService?.selectFiles(_('Select Cover Image'), exts)) || [];
if (appService?.isIOSApp) {
return files.filter((file) => {
const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown';
return SUPPORTED_IMAGE_EXTS.includes(fileExt);
});
}
return files;
};
const handleSelectLocalImage = async () => {
let files;
if (isTauriAppPlatform()) {
files = (await selectImageFileTauri()) as string[];
if (appService && files.length > 0) {
metadata.coverImageFile = files[0]!;
selectFiles({ type: 'covers', multiple: false }).then(async (result) => {
if (result.error || result.files.length === 0) return;
const selectedFile = result.files[0]!;
if (selectedFile.path && appService) {
const filePath = selectedFile.path;
metadata.coverImageFile = filePath;
const tempName = `cover-${Date.now()}.png`;
const cachePrefix = await appService.fs.getPrefix('Cache');
await appService.fs.copyFile(files[0]!, tempName, 'Cache');
await appService.fs.copyFile(filePath, tempName, 'Cache');
metadata.coverImageUrl = await appService.fs.getURL(`${cachePrefix}/${tempName}`);
setNewCoverImageUrl(metadata.coverImageUrl!);
}
} else {
files = (await selectImageFileWeb()) as File[];
if (files.length > 0) {
metadata.coverImageBlobUrl = URL.createObjectURL(files[0]!);
} else if (selectedFile.file) {
metadata.coverImageBlobUrl = URL.createObjectURL(selectedFile.file);
setNewCoverImageUrl(metadata.coverImageBlobUrl!);
}
}
});
};
return (
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react';
import { Book } from '@/types/book';
import { BookMetadata } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useMetadataEdit } from './useMetadataEdit';
@@ -45,6 +46,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
handleBookMetadataUpdate,
}) => {
const _ = useTranslation();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
const [editMode, setEditMode] = useState(false);
@@ -239,10 +241,10 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
{activeDeleteAction && currentDeleteConfig && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
<Alert
title={currentDeleteConfig.title}
+33 -9
View File
@@ -1,9 +1,11 @@
import { AppService } from '@/types/system';
import { isTauriAppPlatform } from '@/services/environment';
import { basename } from '@tauri-apps/api/path';
import { stubTranslation as _ } from '@/utils/misc';
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
export interface FileSelectorOptions {
type: SelectionType;
accept?: string;
multiple?: boolean;
extensions?: string[];
@@ -42,15 +44,24 @@ const selectFileTauri = async (
appService: AppService,
_: (key: string) => string,
): Promise<string[]> => {
const exts = appService?.isIOSApp ? [] : options.extensions || [];
const noFilter = appService?.isIOSApp || (appService?.isAndroidApp && options.type === 'books');
const exts = noFilter ? [] : options.extensions || [];
const title = options.dialogTitle || _('Select Files');
const files = (await appService?.selectFiles(_(title), exts)) || [];
let files = (await appService?.selectFiles(_(title), exts)) || [];
if (appService?.isIOSApp && options.extensions) {
return files.filter((file: string) => {
const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown';
return options.extensions!.includes(fileExt);
});
if (noFilter && options.extensions) {
files = await Promise.all(
files.map(async (file: string) => {
let processedFile = file;
if (appService?.isAndroidApp && file.startsWith('content://')) {
processedFile = await basename(file);
}
const fileExt = processedFile.split('.').pop()?.toLowerCase() || 'unknown';
const extensions = options.extensions!;
const shouldInclude = extensions.includes(fileExt) || extensions.includes('*');
return shouldInclude ? file : null;
}),
).then((results) => results.filter((file) => file !== null));
}
return files;
@@ -69,9 +80,10 @@ const processTauriFiles = (files: string[]): SelectedFile[] => {
};
export const useFileSelector = (appService: AppService | null, _: (key: string) => string) => {
const selectFiles = async (options: FileSelectorOptions = {}) => {
const selectFiles = async (options: FileSelectorOptions = { type: 'generic' }) => {
options = { ...FILE_SELECTION_PRESETS[options.type], ...options };
if (!appService) {
return { files: [], error: 'App service is not available' };
return { files: [] as SelectedFile[], error: 'App service is not available' };
}
try {
if (isTauriAppPlatform()) {
@@ -96,6 +108,11 @@ export const useFileSelector = (appService: AppService | null, _: (key: string)
};
export const FILE_SELECTION_PRESETS = {
generic: {
accept: '*/*',
extensions: ['*'],
dialogTitle: _('Select Files'),
},
images: {
accept: 'image/*',
extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
@@ -121,4 +138,11 @@ export const FILE_SELECTION_PRESETS = {
extensions: ['ttf', 'otf', 'woff', 'woff2'],
dialogTitle: _('Select Fonts'),
},
covers: {
accept: '.png, .jpg, .jpeg, .gif',
extensions: ['png', 'jpg', 'jpeg', 'gif'],
dialogTitle: _('Select Image'),
},
};
export type SelectionType = keyof typeof FILE_SELECTION_PRESETS;
+46 -10
View File
@@ -1,6 +1,10 @@
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { getSafeAreaInsets } from '@/utils/bridge';
import { useState, useEffect, useCallback } from 'react';
export const useSafeAreaInsets = () => {
const { appService } = useEnv();
const [updated, setUpdated] = useState(false);
const [insets, setInsets] = useState({
top: 0,
@@ -9,27 +13,59 @@ export const useSafeAreaInsets = () => {
left: 0,
});
const updateSafeAreaInsets = useCallback(() => {
const { updateSafeAreaInsets } = useThemeStore();
const onUpdateInsets = useCallback(() => {
if (!appService) return;
if (!appService.hasSafeAreaInset) {
updateSafeAreaInsets(insets);
setUpdated(true);
return;
}
const rootStyles = getComputedStyle(document.documentElement);
const hasCustomProperties = rootStyles.getPropertyValue('--safe-area-inset-top');
if (hasCustomProperties) {
setInsets({
const isWebView139 = /Chrome\/139/.test(navigator.userAgent);
// safe-area-inset-* values in css are always 0px in some versions of webview 139
// due to https://issues.chromium.org/issues/40699457
if (appService.isAndroidApp && isWebView139) {
getSafeAreaInsets().then((response) => {
if (response.error) {
console.error('Error getting safe area insets from native bridge:', response.error);
} else {
const insets = {
top: response.top,
right: response.right,
bottom: response.bottom,
left: response.left,
};
setInsets(insets);
updateSafeAreaInsets(insets);
setUpdated(true);
}
});
} else if (hasCustomProperties) {
const insets = {
top: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-top')) || 0,
right: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-right')) || 0,
bottom: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-bottom')) || 0,
left: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-left')) || 0,
});
};
setInsets(insets);
updateSafeAreaInsets(insets);
setUpdated(true);
}
setUpdated(true);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
useEffect(() => {
updateSafeAreaInsets();
window.addEventListener('resize', updateSafeAreaInsets);
onUpdateInsets();
window.addEventListener('resize', onUpdateInsets);
return () => {
window.removeEventListener('resize', updateSafeAreaInsets);
window.removeEventListener('resize', onUpdateInsets);
};
}, [updateSafeAreaInsets]);
}, [onUpdateInsets]);
return updated ? insets : null;
};
+61 -37
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useSyncContext } from '@/context/SyncContext';
import { SyncData, SyncOp, SyncResult, SyncType } from '@/libs/sync';
@@ -47,7 +47,7 @@ export function useSync(bookKey?: string) {
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(0);
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(0);
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(0);
const lastSyncedAtInited = useRef(false);
const [lastSyncedAtInited, setLastSyncedAtInited] = useState(false);
const [syncing, setSyncing] = useState(false);
// null means unsynced, empty array means synced no changes
@@ -63,17 +63,19 @@ export function useSync(bookKey?: string) {
const { syncClient } = useSyncContext();
useEffect(() => {
if (!settings || !config) return;
if (lastSyncedAtInited.current) return;
lastSyncedAtInited.current = true;
if (!settings.version) return;
if (bookKey && !config?.location) return;
if (lastSyncedAtInited) return;
const lastSyncedBooksAt = settings.lastSyncedAtBooks ?? 0;
const lastSyncedConfigsAt = config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0;
const lastSyncedNotesAt = config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0;
setLastSyncedAtBooks(lastSyncedBooksAt > 0 ? lastSyncedBooksAt - SEVEN_DAYS_IN_MS : 0);
setLastSyncedAtBooks(lastSyncedBooksAt > 0 ? lastSyncedBooksAt : 0);
setLastSyncedAtConfigs(lastSyncedConfigsAt > 0 ? lastSyncedConfigsAt - SEVEN_DAYS_IN_MS : 0);
setLastSyncedAtNotes(lastSyncedNotesAt > 0 ? lastSyncedNotesAt - SEVEN_DAYS_IN_MS : 0);
}, [settings, config]);
setLastSyncedAtInited(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey, settings, config]);
// bookId is for configs and notes only, if bookId is provided, only pull changes for that book
// and update the lastSyncedAt for that book in the book config
@@ -158,38 +160,60 @@ export function useSync(bookKey?: string) {
}
};
const syncBooks = async (books?: Book[], op: SyncOp = 'both') => {
if ((op === 'push' || op === 'both') && books?.length) {
await pushChanges({ books });
}
if (op === 'pull' || op === 'both') {
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
}
};
const syncBooks = useCallback(
async (books?: Book[], op: SyncOp = 'both') => {
if (!lastSyncedAtInited) return;
if ((op === 'push' || op === 'both') && books?.length) {
await pushChanges({ books });
}
if (op === 'pull' || op === 'both') {
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastSyncedAtInited, lastSyncedAtBooks],
);
const syncConfigs = async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
await pushChanges({ configs: bookConfigs });
}
if (op === 'pull' || op === 'both') {
await pullChanges(
'configs',
lastSyncedAtConfigs,
setLastSyncedAtConfigs,
setSyncingConfigs,
bookId,
);
}
};
const syncConfigs = useCallback(
async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
if (!bookId && !lastSyncedAtInited) return;
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
await pushChanges({ configs: bookConfigs });
}
if (op === 'pull' || op === 'both') {
await pullChanges(
'configs',
lastSyncedAtConfigs,
setLastSyncedAtConfigs,
setSyncingConfigs,
bookId,
);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastSyncedAtInited, lastSyncedAtConfigs],
);
const syncNotes = async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
if ((op === 'push' || op === 'both') && bookNotes?.length) {
await pushChanges({ notes: bookNotes });
}
if (op === 'pull' || op === 'both') {
await pullChanges('notes', lastSyncedAtNotes, setLastSyncedAtNotes, setSyncingNotes, bookId);
}
};
const syncNotes = useCallback(
async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
if (!lastSyncedAtInited) return;
if ((op === 'push' || op === 'both') && bookNotes?.length) {
await pushChanges({ notes: bookNotes });
}
if (op === 'pull' || op === 'both') {
await pullChanges(
'notes',
lastSyncedAtNotes,
setLastSyncedAtNotes,
setSyncingNotes,
bookId,
);
}
return true;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastSyncedAtInited, lastSyncedAtNotes],
);
useEffect(() => {
if (!syncing && syncResult) {
+1 -1
View File
@@ -38,7 +38,7 @@ export const useTheme = ({
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService?.isAndroidApp]);
const handleSystemUIVisibility = useCallback(() => {
if (!appService?.isMobileApp) return;
+13 -1
View File
@@ -101,6 +101,8 @@ export const EXTS: Record<BookFormat, string> = {
EPUB: 'epub',
PDF: 'pdf',
MOBI: 'mobi',
AZW: 'azw',
AZW3: 'azw3',
CBZ: 'cbz',
FB2: 'fb2',
FBZ: 'fbz',
@@ -228,7 +230,17 @@ export class DocumentLoader {
const fflate = await import('foliate-js/vendor/fflate.js');
const { MOBI } = await import('foliate-js/mobi.js');
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(this.file);
format = 'MOBI';
const ext = this.file.name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'azw':
format = 'AZW';
break;
case 'azw3':
format = 'AZW3';
break;
default:
format = 'MOBI';
}
} else if (this.isFB2()) {
const { makeFB2 } = await import('foliate-js/fb2.js');
book = await makeFB2(this.file);
+6 -5
View File
@@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { KoSyncProxyPayload } from '@/types/kosync';
const validEndpoints = [/\/users\/create/, /\/users\/auth/, /\/syncs\/progress/];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
@@ -21,6 +23,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ error: 'serverUrl and endpoint are required' });
}
if (!validEndpoints.some((regex) => regex.test(endpoint))) {
return res.status(400).json({ error: 'Invalid endpoint' });
}
const targetUrl = `${serverUrl.replace(/\/$/, '')}${endpoint}`;
try {
@@ -34,11 +40,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
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 {
@@ -1,7 +1,11 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createSupabaseClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { getStoragePlanData, validateUserAndToken } from '@/utils/access';
import {
getStoragePlanData,
validateUserAndToken,
STORAGE_QUOTA_GRACE_BYTES,
} from '@/utils/access';
import { getUploadSignedUrl } from '@/utils/object';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -23,7 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const { usage, quota } = getStoragePlanData(token);
if (usage + fileSize > quota) {
if (usage + fileSize > quota + STORAGE_QUOTA_GRACE_BYTES) {
return res.status(403).json({ error: 'Insufficient storage quota', usage });
}
+3 -2
View File
@@ -116,8 +116,8 @@ export abstract class BaseAppService implements AppService {
...settings.globalViewSettings,
};
if (!settings.koreaderSyncDeviceId) {
settings.koreaderSyncDeviceId = uuidv4();
if (!settings.kosync.deviceId) {
settings.kosync.deviceId = uuidv4();
await this.fs.writeFile(fp, base, JSON.stringify(settings));
}
} catch {
@@ -218,6 +218,7 @@ export abstract class BaseAppService implements AppService {
};
// update book metadata when reimporting the same book
if (existingBook) {
existingBook.format = book.format;
existingBook.title = existingBook.title ?? book.title;
existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle;
existingBook.author = existingBook.author ?? book.author;
+13 -9
View File
@@ -10,7 +10,7 @@ import {
ViewConfig,
ViewSettings,
} from '@/types/book';
import { ReadSettings, SystemSettings } from '@/types/settings';
import { KOSyncSettings, ReadSettings, SystemSettings } from '@/types/settings';
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/user';
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
import { stubTranslation as _ } from '@/utils/misc';
@@ -37,6 +37,17 @@ export const BOOK_UNGROUPED_ID = '';
export const SUPPORTED_IMAGE_EXTS = ['png', 'jpg', 'jpeg'];
export const IMAGE_ACCEPT_FORMATS = SUPPORTED_IMAGE_EXTS.map((ext) => `.${ext}`).join(', ');
export const DEFAULT_KOSYNC_SETTINGS = {
serverUrl: 'https://sync.koreader.rocks/', // https://kosync.ak-team.com:3042/
username: '',
userkey: '',
deviceId: '',
deviceName: '',
checksumMethod: 'binary',
strategy: 'prompt',
enabled: false,
} as KOSyncSettings;
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
keepLogin: false,
autoUpload: true,
@@ -54,14 +65,7 @@ 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,
kosync: DEFAULT_KOSYNC_SETTINGS,
lastSyncedAtBooks: 0,
lastSyncedAtConfigs: 0,
@@ -1,10 +1,9 @@
import { md5 } from 'js-md5';
import { Book } from '@/types/book';
import { KoreaderSyncChecksumMethod } from '@/types/settings';
import { KOSyncSettings } from '@/types/settings';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { KoSyncProxyPayload } from '@/types/kosync';
import { isLanAddress } from '@/utils/network';
import { getBaseFilename } from '@/utils/path';
import { getAPIBaseUrl, isTauriAppPlatform } from '../environment';
/**
@@ -20,29 +19,13 @@ export interface KoSyncProgress {
}
export class KOSyncClient {
private serverUrl: string;
private username: string;
private userkey: string;
private checksumMethod: KoreaderSyncChecksumMethod;
private deviceId: string;
private deviceName: string;
private config: KOSyncSettings;
private isLanServer: boolean;
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;
this.isLanServer = isLanAddress(this.serverUrl);
constructor(config: KOSyncSettings) {
this.config = config;
this.config.serverUrl = config.serverUrl.replace(/\/$/, '');
this.isLanServer = isLanAddress(this.config.serverUrl);
}
private async request(
@@ -58,18 +41,19 @@ export class KOSyncClient {
const headers = new Headers(additionalHeaders || {});
if (useAuth) {
headers.set('X-Auth-User', this.username);
headers.set('X-Auth-Key', this.userkey);
headers.set('X-Auth-User', this.config.username);
headers.set('X-Auth-Key', this.config.userkey);
}
if (this.isLanServer) {
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const directUrl = `${this.serverUrl}${endpoint}`;
const directUrl = `${this.config.serverUrl}${endpoint}`;
return fetch(directUrl, {
method,
headers: {
accept: 'application/vnd.koreader.v1+json',
...(method === 'GET' ? {} : { 'Content-Type': 'application/json' }),
...Object.fromEntries(headers.entries()),
},
body,
@@ -83,7 +67,7 @@ export class KOSyncClient {
const proxyUrl = `${getAPIBaseUrl()}/kosync`;
const proxyBody: KoSyncProxyPayload = {
serverUrl: this.serverUrl,
serverUrl: this.config.serverUrl,
endpoint,
method,
headers: Object.fromEntries(headers.entries()),
@@ -159,7 +143,7 @@ export class KOSyncClient {
* @returns Promise with the progress data or null if not found
*/
async getProgress(book: Book): Promise<KoSyncProgress | null> {
if (!this.userkey) return null;
if (!this.config.userkey) return null;
const documentHash = this.getDocumentDigest(book);
if (!documentHash) return null;
@@ -190,17 +174,17 @@ export class KOSyncClient {
* @returns Promise with boolean indicating success
*/
async updateProgress(book: Book, progress: string, percentage: number): Promise<boolean> {
if (!this.userkey) return false;
if (!this.config.userkey) return false;
const documentHash = this.getDocumentDigest(book);
if (!documentHash) return false;
const payload = {
document: documentHash,
progress: progress.toString(),
progress,
percentage,
device: this.deviceName,
device_id: this.deviceId,
device: this.config.deviceName,
device_id: this.config.deviceId,
};
try {
@@ -222,10 +206,9 @@ export class KOSyncClient {
}
}
private getDocumentDigest(book: Book): string | undefined {
if (this.checksumMethod === 'filename') {
const filename = getBaseFilename(book.sourceTitle || book.title);
return md5(filename);
getDocumentDigest(book: Book): string {
if (this.config.checksumMethod === 'filename') {
console.warn('This is not possible anymore, using md5 instead.');
}
return book.hash;
}
+16 -15
View File
@@ -8,7 +8,10 @@ interface FontStoreState {
loading: boolean;
setFonts: (fonts: CustomFont[]) => void;
addFont: (path: string, options?: { name?: string }) => CustomFont;
addFont: (
path: string,
options?: { name?: string; family?: string; style?: string },
) => CustomFont;
removeFont: (id: string) => boolean;
updateFont: (id: string, updates: Partial<CustomFont>) => boolean;
getFont: (id: string) => CustomFont | undefined;
@@ -45,20 +48,18 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
const font = createCustomFont(path, options);
const existingFont = get().fonts.find((f) => f.id === font.id);
if (existingFont) {
if (existingFont.deletedAt) {
get().updateFont(font.id, {
path: font.path,
downloadedAt: Date.now(),
deletedAt: undefined,
loaded: false,
blobUrl: undefined,
error: undefined,
});
set((state) => ({
fonts: [...state.fonts],
}));
return existingFont;
}
get().updateFont(font.id, {
...font,
path: font.path,
downloadedAt: Date.now(),
deletedAt: undefined,
loaded: false,
blobUrl: undefined,
error: undefined,
});
set((state) => ({
fonts: [...state.fonts],
}));
return existingFont;
}
+7
View File
@@ -5,6 +5,7 @@ import { getSystemColorScheme } from '@/utils/bridge';
import { CustomTheme, Palette, ThemeMode } from '@/styles/themes';
import { EnvConfigType, isWebAppPlatform } from '@/services/environment';
import { SystemSettings } from '@/types/settings';
import { Insets } from '@/types/misc';
interface ThemeState {
themeMode: ThemeMode;
@@ -15,6 +16,7 @@ interface ThemeState {
systemUIVisible: boolean;
statusBarHeight: number;
systemUIAlwaysHidden: boolean;
safeAreaInsets: Insets | null;
setSystemUIAlwaysHidden: (hidden: boolean) => void;
setStatusBarHeight: (height: number) => void;
showSystemUI: () => void;
@@ -30,6 +32,7 @@ interface ThemeState {
isDelete?: boolean,
) => void;
handleSystemThemeChange: (isDark: boolean) => void;
updateSafeAreaInsets: (insets: Insets) => void;
}
const getInitialThemeMode = (): ThemeMode => {
@@ -64,6 +67,7 @@ export const useThemeStore = create<ThemeState>((set, get) => {
systemUIVisible: false,
statusBarHeight: 24,
systemUIAlwaysHidden: false,
safeAreaInsets: null,
showSystemUI: () => set({ systemUIVisible: true }),
dismissSystemUI: () => set({ systemUIVisible: false }),
setStatusBarHeight: (height: number) => set({ statusBarHeight: height }),
@@ -122,6 +126,9 @@ export const useThemeStore = create<ThemeState>((set, get) => {
const isDarkMode = mode === 'dark' || (mode === 'auto' && systemIsDarkMode);
set({ systemIsDarkMode, isDarkMode });
},
updateSafeAreaInsets: (insets) => {
set({ safeAreaInsets: insets });
},
};
});
+5 -1
View File
@@ -118,6 +118,8 @@ export const mountAdditionalFonts = (document: Document, isCJK = false) => {
export interface CustomFont {
id: string;
name: string;
family?: string;
style?: string;
path: string;
downloadedAt?: number;
deletedAt?: number;
@@ -189,10 +191,12 @@ export function createCustomFont(
path: string,
options?: {
name?: string;
family?: string;
style?: string;
},
): CustomFont {
const name = options?.name || getFontName(path);
return { id: getFontId(name), name, path };
return { id: getFontId(name), name, family: options?.family, style: options?.style, path };
}
export const mountCustomFont = (document: Document, font: CustomFont) => {
+2 -2
View File
@@ -100,13 +100,13 @@ foliate-view {
.window-border {
border-radius: 10px;
z-index: 999;
z-index: 99;
position: relative;
}
.window-border::before {
content: '';
z-index: 1000;
z-index: 999;
position: absolute;
top: 0;
left: 0;
+1 -1
View File
@@ -1,6 +1,6 @@
import { BookMetadata } from '@/libs/document';
export type BookFormat = 'EPUB' | 'PDF' | 'MOBI' | 'CBZ' | 'FB2' | 'FBZ';
export type BookFormat = 'EPUB' | 'PDF' | 'MOBI' | 'AZW' | 'AZW3' | 'CBZ' | 'FB2' | 'FBZ';
export type BookNoteType = 'bookmark' | 'annotation' | 'excerpt';
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet';
+14 -10
View File
@@ -7,8 +7,8 @@ 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 type KOSyncChecksumMethod = 'binary' | 'filename';
export type KOSyncStrategy = 'prompt' | 'silent' | 'send' | 'receive';
export interface ReadSettings {
sideBarWidth: string;
@@ -24,6 +24,17 @@ export interface ReadSettings {
customThemes: CustomTheme[];
}
export interface KOSyncSettings {
enabled: boolean;
serverUrl: string;
username: string;
userkey: string;
deviceId: string;
deviceName: string;
checksumMethod: KOSyncChecksumMethod;
strategy: KOSyncStrategy;
}
export interface SystemSettings {
version: number;
localBooksDir: string;
@@ -45,14 +56,7 @@ export interface SystemSettings {
libraryCoverFit: LibraryCoverFitType;
customFonts: CustomFont[];
koreaderSyncServerUrl: string;
koreaderSyncUsername: string;
koreaderSyncUserkey: string;
koreaderSyncDeviceId: string;
koreaderSyncDeviceName: string;
koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod;
koreaderSyncStrategy: KoreaderSyncStrategy;
koreaderSyncPercentageTolerance: number;
kosync: KOSyncSettings;
lastSyncedAtBooks: number;
lastSyncedAtConfigs: number;
+2
View File
@@ -16,6 +16,8 @@ export const getUserPlan = (token: string): UserPlan => {
return data['plan'] || 'free';
};
export const STORAGE_QUOTA_GRACE_BYTES = 10 * 1024 * 1024; // 10 MB grace
export const getStoragePlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan = data['plan'] || 'free';
+15
View File
@@ -57,6 +57,14 @@ export interface GetSystemColorSchemeResponse {
error?: string;
}
export interface GetSafeAreaInsetsResponse {
top: number;
right: number;
bottom: number;
left: number;
error?: string;
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
@@ -130,3 +138,10 @@ export async function getSystemColorScheme(): Promise<GetSystemColorSchemeRespon
);
return result;
}
export async function getSafeAreaInsets(): Promise<GetSafeAreaInsetsResponse> {
const result = await invoke<GetSafeAreaInsetsResponse>(
'plugin:native-bridge|get_safe_area_insets',
);
return result;
}
+46 -19
View File
@@ -20,6 +20,8 @@ function parseMacintoshString(dataView: DataView, offset: number, length: number
return chars.join('');
}
const NO_STYLE_LANGUAGE_IDS = new Set([0x0404, 0x0804, 0x0c04, 0x1004, 19, 33]);
function getLanguagePriority(platformID: number, languageID: number, userLanguage: string): number {
let priority = 0;
@@ -75,7 +77,14 @@ function getLanguagePriority(platformID: number, languageID: number, userLanguag
return priority;
}
export const parseFontFamily = (fontData: ArrayBuffer, filename: string) => {
type FontNameType = {
name: string;
platformID: number;
languageID: number;
priority: number;
};
export const parseFontName = (fontData: ArrayBuffer, filename: string) => {
const fallbackName = filename.replace(/\.[^/.]+$/, '');
try {
const dataView = new DataView(fontData);
@@ -108,12 +117,8 @@ export const parseFontFamily = (fontData: ArrayBuffer, filename: string) => {
const stringOffset = dataView.getUint16(nameTableOffset + 4, false);
const userLanguage = getUserLang();
const fontNames: Array<{
name: string;
platformID: number;
languageID: number;
priority: number;
}> = [];
const fontFamilyNames: Array<FontNameType> = [];
const fontStyleNames: Array<FontNameType> = [];
for (let i = 0; i < count; i++) {
const recordOffset = nameTableOffset + 6 + i * 12;
const platformID = dataView.getUint16(recordOffset, false);
@@ -122,36 +127,58 @@ export const parseFontFamily = (fontData: ArrayBuffer, filename: string) => {
const nameLength = dataView.getUint16(recordOffset + 8, false);
const nameOffsetInTable = dataView.getUint16(recordOffset + 10, false);
if (nameID === 1) {
// nameID 1 = Font Family name, nameID 2 = Font Subfamily name (style)
if (nameID === 1 || nameID === 2) {
const stringStart = nameTableOffset + stringOffset + nameOffsetInTable;
let familyName = '';
let fontName = '';
if (platformID === 0 || platformID === 3) {
// Unicode/Microsoft platform
familyName = parseUnicodeString(dataView, stringStart, nameLength);
fontName = parseUnicodeString(dataView, stringStart, nameLength);
} else if (platformID === 1) {
// Macintosh platform
familyName = parseMacintoshString(dataView, stringStart, nameLength);
fontName = parseMacintoshString(dataView, stringStart, nameLength);
}
if (familyName && familyName.trim()) {
if (fontName && fontName.trim()) {
const priority = getLanguagePriority(platformID, languageID, userLanguage);
fontNames.push({
name: familyName.trim(),
const nameEntry = {
name: fontName.trim(),
platformID,
languageID,
priority,
});
};
if (nameID === 1) {
fontFamilyNames.push(nameEntry);
} else if (nameID === 2) {
fontStyleNames.push(nameEntry);
}
}
}
}
if (fontNames.length === 0) {
if (fontFamilyNames.length === 0) {
throw new Error('Font family name not found');
}
fontNames.sort((a, b) => b.priority - a.priority);
return fontNames[0]!.name;
fontFamilyNames.sort((a, b) => b.priority - a.priority);
fontStyleNames.sort((a, b) => b.priority - a.priority);
const fontStyleName = fontStyleNames[0];
const familyName = fontFamilyNames[0]!.name;
const styleName = fontStyleName?.name || '';
return {
name:
fontStyleName && !NO_STYLE_LANGUAGE_IDS.has(fontStyleName.languageID)
? `${familyName} ${styleName}`
: familyName,
family: familyName,
style: styleName,
};
} catch (error) {
console.warn(`Failed to parse font: ${error}`);
return fallbackName;
return {
name: fallbackName,
family: fallbackName,
style: '',
};
}
};
+5 -1
View File
@@ -1,4 +1,5 @@
import { useRouter, redirect } from 'next/navigation';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { isPWA, isWebAppPlatform } from '@/services/environment';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
@@ -6,7 +7,10 @@ import { AppService } from '@/types/system';
let readerWindowsCount = 0;
const createReaderWindow = (appService: AppService, url: string) => {
const win = new WebviewWindow(`reader-${readerWindowsCount}`, {
const currentWindow = getCurrentWindow();
const label = currentWindow.label;
const newLabelPrefix = label === 'main' ? 'reader' : label;
const win = new WebviewWindow(`${newLabelPrefix}-${readerWindowsCount}`, {
url,
width: 800,
height: 600,
+6 -5
View File
@@ -3,11 +3,12 @@ export function formatReadingProgress(
total: number | undefined,
template: string,
): string {
if (current === undefined || total === undefined || total <= 0 || current < 0) {
if (current !== undefined && total !== undefined && total > 0 && current >= 0) {
return template
.replace('{current}', String(current + 1))
.replace('{total}', String(total))
.replace('{percent}', (((current + 1) / total) * 100).toFixed(1));
} else {
return '';
}
return template
.replace('{current}', String(current + 1))
.replace('{total}', String(total))
.replace('{percent}', (((current + 1) / total) * 100).toFixed(1));
}

Some files were not shown because too many files have changed in this diff Show More