forked from akai/readest
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e472d1cf1e | |||
| 75dc04d598 | |||
| fc4c033cf3 | |||
| dbee054838 | |||
| 45b805dcd4 | |||
| a8ac54b51c | |||
| 86f705eae9 | |||
| d390209dab | |||
| 149f94be4c | |||
| ef1a1eab98 | |||
| dc7c6e14bc | |||
| 8c31dc85f9 | |||
| 7b8d1ddc35 | |||
| 8c12399732 | |||
| 93a4b1a448 | |||
| 04b0752b22 | |||
| 5137c08204 | |||
| ec0b44fb9f | |||
| 0bf83dfe67 | |||
| b151b70cb9 | |||
| f2640359a0 | |||
| 079aeaedb0 | |||
| bfacadb964 | |||
| 2e98ed44ee | |||
| 952d2a3553 | |||
| 225aa0ca59 | |||
| 37f718b84b | |||
| db8f53a6e1 | |||
| ce345ad67f | |||
| 8e53e625a4 |
@@ -11,7 +11,7 @@ jobs:
|
||||
env:
|
||||
RUSTFLAGS: '-C target-cpu=skylake'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: 'true'
|
||||
- name: Install minimal stable with clippy and rustfmt
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
- platform: 'web'
|
||||
- platform: 'tauri'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
@@ -57,8 +57,10 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/readest-app/.next/cache
|
||||
key: nextjs-${{ runner.os }}-${{ hashFiles('apps/readest-app/package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: nextjs-${{ runner.os }}-
|
||||
key: nextjs-${{ matrix.config.platform }}-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
nextjs-${{ matrix.config.platform }}-${{ github.sha }}-
|
||||
nextjs-${{ matrix.config.platform }}-
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
release_version: ${{ steps.get-release-notes.outputs.release_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
- name: get version
|
||||
@@ -48,6 +48,39 @@ jobs:
|
||||
core.setOutput('release_version', version);
|
||||
core.setOutput('release_note', releaseNote);
|
||||
|
||||
update-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-release
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: process.env.release_tag,
|
||||
})
|
||||
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
|
||||
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
|
||||
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
build-tauri:
|
||||
needs: get-release
|
||||
permissions:
|
||||
@@ -91,7 +124,7 @@ jobs:
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
@@ -288,39 +321,6 @@ jobs:
|
||||
echo "Uploading $bin_file to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
|
||||
|
||||
update-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-release, build-tauri]
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: process.env.release_tag,
|
||||
})
|
||||
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
|
||||
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
|
||||
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
upload-to-r2:
|
||||
needs: [get-release, update-release]
|
||||
uses: ./.github/workflows/upload-to-r2.yml
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@v25
|
||||
|
||||
@@ -40,3 +40,6 @@ next-env.d.ts
|
||||
target
|
||||
|
||||
fastlane/report.xml
|
||||
|
||||
*.koplugin.zip
|
||||
|
||||
|
||||
Generated
+374
-376
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ rust-version = "1.77.2"
|
||||
tauri = { path = "packages/tauri/crates/tauri" }
|
||||
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
|
||||
tauri-build = { path = "packages/tauri/crates/tauri-build" }
|
||||
tauri-plugin-os = { path = "packages/tauri-plugins/plugins/os" }
|
||||
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
|
||||
tauri-plugin-dialog = { path = "packages/tauri-plugins/plugins/dialog" }
|
||||
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.71",
|
||||
"version": "0.9.75",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -47,19 +47,19 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@opennextjs/cloudflare": "^1.6.1",
|
||||
"@opennextjs/cloudflare": "^1.6.5",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.4.0",
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.50.2",
|
||||
"@tauri-apps/api": "2.6.0",
|
||||
"@supabase/supabase-js": "^2.55.0",
|
||||
"@tauri-apps/api": "2.8.0",
|
||||
"@tauri-apps/plugin-cli": "^2.4.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.1",
|
||||
"@tauri-apps/plugin-haptics": "^2.3.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.0",
|
||||
@@ -84,7 +84,7 @@
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"next": "15.3.3",
|
||||
"next": "15.5.0",
|
||||
"overlayscrollbars": "^2.11.4",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"posthog-js": "^1.246.0",
|
||||
@@ -105,7 +105,7 @@
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^15.4.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.7.0",
|
||||
"@tauri-apps/cli": "2.8.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -137,6 +137,6 @@
|
||||
"typescript": "^5.7.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.26.0"
|
||||
"wrangler": "^4.31.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,5 +520,18 @@
|
||||
"Failed to connect": "فشل في الاتصال",
|
||||
"Sync Server Connected": "تم الاتصال بخادم المزامنة",
|
||||
"Precision: {{precision}} digits after the decimal": "الدقة: {{precision}} أرقام بعد الفاصلة",
|
||||
"Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}",
|
||||
"Custom Fonts": "الخطوط المخصصة",
|
||||
"Cancel Delete": "إلغاء الحذف",
|
||||
"Import Font": "استيراد خط",
|
||||
"Delete Font": "حذف الخط",
|
||||
"Tips": "نصائح",
|
||||
"Custom fonts can be selected from the Font Face menu": "يمكن اختيار الخطوط المخصصة من قائمة نوع الخط",
|
||||
"Manage Custom Fonts": "إدارة الخطوط المخصصة",
|
||||
"Select Files": "حدد الملفات",
|
||||
"Select Image": "حدد صورة",
|
||||
"Select Video": "حدد فيديو",
|
||||
"Select Audio": "حدد صوت",
|
||||
"Select Fonts": "حدد الخطوط",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "تنسيقات الخط المدعومة: .ttf، .odf، .woff، .woff2"
|
||||
}
|
||||
|
||||
@@ -498,7 +498,20 @@
|
||||
"Remote Progress": "བརྒྱབ་འབྱུང་ཁུངས།",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
|
||||
"Page {{page}} of {{total}} ({{percentage}}%)": "ཤོག་ {{page}} དང་ {{total}} ({{percentage}}%)",
|
||||
"Current position": "ད་ལྟ་བཞིན་འདེམས་པ།",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ད་ལྟ་བཞིན་འདེམས་པ།",
|
||||
"Approximately {{percentage}}%": "ད་ལྟ་བཞིན་འདེམས་པ།"
|
||||
"Current position": "ད་ལྟའི་གནས་ས།",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ཕྲན་བུ་ཤོག་ངོས་ {{page}} / {{total}} ({{percentage}}%)",
|
||||
"Approximately {{percentage}}%": "ཕྲན་བུ་ {{percentage}}%",
|
||||
"Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས།",
|
||||
"Cancel Delete": "སུབ་པ་འདོར་བ།",
|
||||
"Import Font": "ཡིག་གཟུགས་འདྲེན་འཇུག",
|
||||
"Delete Font": "ཡིག་གཟུགས་སུབ་པ།",
|
||||
"Tips": "གསལ་འདེབས།",
|
||||
"Custom fonts can be selected from the Font Face menu": "རང་བཞིན་ཡིག་གཟུགས་ནི་ཡིག་གཟུགས་ངོ་བོའི་ཟ་འབྲིང་ནས་འདེམས་ཆོག",
|
||||
"Manage Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས་འཛིན་སྐྱོང་།",
|
||||
"Select Files": "ཡིག་ཆ་འདེམས་པ།",
|
||||
"Select Image": "རི་མོ་འདེམས་པ།",
|
||||
"Select Video": "བརྙན་ཟློས་འདེམས་པ།",
|
||||
"Select Audio": "སྒྲ་ཟློས་འདེམས་པ།",
|
||||
"Select Fonts": "ཡིག་གཟུགས་འདེམས་པ།",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "ཡིག་གཟུགས་འདེམས་པ་བྱས་ནས་བསྐྱར་བརྗེ་བ། ཡིག་གཟུགས་ཀྱི་སྒྲ་སྐད། .ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -504,5 +504,18 @@
|
||||
"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}}"
|
||||
"Sync as {{userDisplayName}}": "Sync als {{userDisplayName}}",
|
||||
"Custom Fonts": "Benutzerdefinierte Schriftarten",
|
||||
"Cancel Delete": "Löschen abbrechen",
|
||||
"Import Font": "Schriftart importieren",
|
||||
"Delete Font": "Schriftart löschen",
|
||||
"Tips": "Tipps",
|
||||
"Custom fonts can be selected from the Font Face menu": "Benutzerdefinierte Schriftarten können aus dem Schriftart-Menü ausgewählt werden",
|
||||
"Manage Custom Fonts": "Benutzerdefinierte Schriftarten verwalten",
|
||||
"Select Files": "Dateien auswählen",
|
||||
"Select Image": "Bild auswählen",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -504,5 +504,18 @@
|
||||
"Failed to connect": "Αποτυχία σύνδεσης",
|
||||
"Sync Server Connected": "Ο διακομιστής συγχρονισμού είναι συνδεδεμένος",
|
||||
"Precision: {{precision}} digits after the decimal": "Ακρίβεια: {{precision}} ψηφία μετά την υποδιαστολή",
|
||||
"Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}",
|
||||
"Custom Fonts": "Προσαρμοσμένες Γραμματοσειρές",
|
||||
"Cancel Delete": "Ακύρωση Διαγραφής",
|
||||
"Import Font": "Εισαγωγή Γραμματοσειράς",
|
||||
"Delete Font": "Διαγραφή Γραμματοσειράς",
|
||||
"Tips": "Συμβουλές",
|
||||
"Custom fonts can be selected from the Font Face menu": "Οι προσαρμοσμένες γραμματοσειρές μπορούν να επιλεγούν από το μενού Γραμματοσειράς",
|
||||
"Manage Custom Fonts": "Διαχείριση Προσαρμοσμένων Γραμματοσειρών",
|
||||
"Select Files": "Επιλογή Αρχείων",
|
||||
"Select Image": "Επιλογή Εικόνας",
|
||||
"Select Video": "Επιλογή Βίντεο",
|
||||
"Select Audio": "Επιλογή Ήχου",
|
||||
"Select Fonts": "Επιλογή Γραμματοσειρών",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "Υποστηριζόμενες μορφές γραμματοσειρών: .ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -508,5 +508,18 @@
|
||||
"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}}"
|
||||
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}",
|
||||
"Custom Fonts": "Fuentes Personalizadas",
|
||||
"Cancel Delete": "Cancelar Eliminación",
|
||||
"Import Font": "Importar Fuente",
|
||||
"Delete Font": "Eliminar Fuente",
|
||||
"Tips": "Consejos",
|
||||
"Custom fonts can be selected from the Font Face menu": "Las fuentes personalizadas se pueden seleccionar desde el menú de Tipografía",
|
||||
"Manage Custom Fonts": "Administrar Fuentes Personalizadas",
|
||||
"Select Files": "Seleccionar Archivos",
|
||||
"Select Image": "Seleccionar Imagen",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -508,5 +508,18 @@
|
||||
"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}}"
|
||||
"Sync as {{userDisplayName}}": "Synchroniser en tant que {{userDisplayName}}",
|
||||
"Custom Fonts": "Polices Personnalisées",
|
||||
"Cancel Delete": "Annuler la Suppression",
|
||||
"Import Font": "Importer une Police",
|
||||
"Delete Font": "Supprimer la Police",
|
||||
"Tips": "Conseils",
|
||||
"Custom fonts can be selected from the Font Face menu": "Les polices personnalisées peuvent être sélectionnées depuis le menu Police",
|
||||
"Manage Custom Fonts": "Gérer les Polices Personnalisées",
|
||||
"Select Files": "Sélectionner des Fichiers",
|
||||
"Select Image": "Sélectionner une Image",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -504,5 +504,18 @@
|
||||
"Failed to connect": "कनेक्ट करने में विफल",
|
||||
"Sync Server Connected": "सिंक सर्वर कनेक्टेड",
|
||||
"Precision: {{precision}} digits after the decimal": "सटीकता: {{precision}} दशमलव के बाद अंक",
|
||||
"Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}",
|
||||
"Custom Fonts": "कस्टम फ़ॉन्ट",
|
||||
"Cancel Delete": "डिलीट रद्द करें",
|
||||
"Import Font": "फ़ॉन्ट आयात करें",
|
||||
"Delete Font": "फ़ॉन्ट डिलीट करें",
|
||||
"Tips": "सुझाव",
|
||||
"Custom fonts can be selected from the Font Face menu": "कस्टम फ़ॉन्ट को फ़ॉन्ट फेस मेनू से चुना जा सकता है",
|
||||
"Manage Custom Fonts": "कस्टम फ़ॉन्ट प्रबंधन",
|
||||
"Select Files": "फ़ाइलें चुनें",
|
||||
"Select Image": "छवि चुनें",
|
||||
"Select Video": "वीडियो चुनें",
|
||||
"Select Audio": "ऑडियो चुनें",
|
||||
"Select Fonts": "फ़ॉन्ट चुनें",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "समर्थित फ़ॉन्ट प्रारूप: .ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -500,5 +500,18 @@
|
||||
"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}}"
|
||||
"Sync as {{userDisplayName}}": "Sinkronkan sebagai {{userDisplayName}}",
|
||||
"Custom Fonts": "Font Kustom",
|
||||
"Cancel Delete": "Batal Hapus",
|
||||
"Import Font": "Impor Font",
|
||||
"Delete Font": "Hapus Font",
|
||||
"Tips": "Tips",
|
||||
"Custom fonts can be selected from the Font Face menu": "Font kustom dapat dipilih dari menu Font Face",
|
||||
"Manage Custom Fonts": "Kelola Font Kustom",
|
||||
"Select Files": "Pilih File",
|
||||
"Select Image": "Pilih Gambar",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -508,5 +508,18 @@
|
||||
"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}}"
|
||||
"Sync as {{userDisplayName}}": "Sincronizza come {{userDisplayName}}",
|
||||
"Custom Fonts": "Font Personalizzati",
|
||||
"Cancel Delete": "Annulla Eliminazione",
|
||||
"Import Font": "Importa Font",
|
||||
"Delete Font": "Elimina Font",
|
||||
"Tips": "Suggerimenti",
|
||||
"Custom fonts can be selected from the Font Face menu": "I font personalizzati possono essere selezionati dal menu Carattere",
|
||||
"Manage Custom Fonts": "Gestisci Font Personalizzati",
|
||||
"Select Files": "Seleziona File",
|
||||
"Select Image": "Seleziona Immagine",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -500,5 +500,18 @@
|
||||
"Failed to connect": "接続に失敗しました",
|
||||
"Sync Server Connected": "同期サーバーに接続されました",
|
||||
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 桁の小数点以下",
|
||||
"Sync as {{userDisplayName}}": "{{userDisplayName}} として同期"
|
||||
"Sync as {{userDisplayName}}": "{{userDisplayName}} として同期",
|
||||
"Custom Fonts": "カスタムフォント",
|
||||
"Cancel Delete": "削除をキャンセル",
|
||||
"Import Font": "フォントをインポート",
|
||||
"Delete Font": "フォントを削除",
|
||||
"Tips": "ヒント",
|
||||
"Custom fonts can be selected from the Font Face menu": "カスタムフォントは書体メニューから選択できます",
|
||||
"Manage Custom Fonts": "カスタムフォント管理",
|
||||
"Select Files": "ファイルを選択",
|
||||
"Select Image": "画像を選択",
|
||||
"Select Video": "動画を選択",
|
||||
"Select Audio": "音声を選択",
|
||||
"Select Fonts": "フォントを選択",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "サポートされているフォント形式:.ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -500,5 +500,18 @@
|
||||
"Failed to connect": "연결 실패",
|
||||
"Sync Server Connected": "동기화 서버에 연결됨",
|
||||
"Precision: {{precision}} digits after the decimal": "정밀도: {{precision}} 자리의 소수점 이하",
|
||||
"Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화"
|
||||
"Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화",
|
||||
"Custom Fonts": "사용자 정의 글꼴",
|
||||
"Cancel Delete": "삭제 취소",
|
||||
"Import Font": "글꼴 가져오기",
|
||||
"Delete Font": "글꼴 삭제",
|
||||
"Tips": "팁",
|
||||
"Custom fonts can be selected from the Font Face menu": "사용자 정의 글꼴은 글꼴 메뉴에서 선택할 수 있습니다",
|
||||
"Manage Custom Fonts": "사용자 정의 글꼴 관리",
|
||||
"Select Files": "파일 선택",
|
||||
"Select Image": "이미지 선택",
|
||||
"Select Video": "동영상 선택",
|
||||
"Select Audio": "오디오 선택",
|
||||
"Select Fonts": "글꼴 선택",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "지원되는 글꼴 형식: .ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -504,5 +504,18 @@
|
||||
"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}}"
|
||||
"Sync as {{userDisplayName}}": "Synchroniseren als {{userDisplayName}}",
|
||||
"Custom Fonts": "Aangepaste Lettertypen",
|
||||
"Cancel Delete": "Verwijderen Annuleren",
|
||||
"Import Font": "Lettertype Importeren",
|
||||
"Delete Font": "Lettertype Verwijderen",
|
||||
"Tips": "Tips",
|
||||
"Custom fonts can be selected from the Font Face menu": "Aangepaste lettertypen kunnen worden geselecteerd vanuit het Lettertype menu",
|
||||
"Manage Custom Fonts": "Aangepaste Lettertypen Beheren",
|
||||
"Select Files": "Bestanden Selecteren",
|
||||
"Select Image": "Afbeelding Selecteren",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -512,5 +512,18 @@
|
||||
"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}}"
|
||||
"Sync as {{userDisplayName}}": "Synchronizuj jako {{userDisplayName}}",
|
||||
"Custom Fonts": "Niestandardowe Czcionki",
|
||||
"Cancel Delete": "Anuluj Usuwanie",
|
||||
"Import Font": "Importuj Czcionkę",
|
||||
"Delete Font": "Usuń Czcionkę",
|
||||
"Tips": "Wskazówki",
|
||||
"Custom fonts can be selected from the Font Face menu": "Niestandardowe czcionki można wybrać z menu Czcionka",
|
||||
"Manage Custom Fonts": "Zarządzaj Niestandardowymi Czcionkami",
|
||||
"Select Files": "Wybierz Pliki",
|
||||
"Select Image": "Wybierz Obraz",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -508,5 +508,18 @@
|
||||
"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}}"
|
||||
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}",
|
||||
"Custom Fonts": "Fontes Personalizadas",
|
||||
"Cancel Delete": "Cancelar Exclusão",
|
||||
"Import Font": "Importar Fonte",
|
||||
"Delete Font": "Excluir Fonte",
|
||||
"Tips": "Dicas",
|
||||
"Custom fonts can be selected from the Font Face menu": "Fontes personalizadas podem ser selecionadas no menu Fonte",
|
||||
"Manage Custom Fonts": "Gerenciar Fontes Personalizadas",
|
||||
"Select Files": "Selecionar Arquivos",
|
||||
"Select Image": "Selecionar Imagem",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -512,5 +512,18 @@
|
||||
"Failed to connect": "Не удалось подключиться",
|
||||
"Sync Server Connected": "Сервер синхронизации подключен",
|
||||
"Precision: {{precision}} digits after the decimal": "Точность: {{precision}} знаков после запятой",
|
||||
"Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}",
|
||||
"Custom Fonts": "Пользовательские Шрифты",
|
||||
"Cancel Delete": "Отменить Удаление",
|
||||
"Import Font": "Импортировать Шрифт",
|
||||
"Delete Font": "Удалить Шрифт",
|
||||
"Tips": "Советы",
|
||||
"Custom fonts can be selected from the Font Face menu": "Пользовательские шрифты можно выбрать в меню Шрифт",
|
||||
"Manage Custom Fonts": "Управление Пользовательскими Шрифтами",
|
||||
"Select Files": "Выбрать Файлы",
|
||||
"Select Image": "Выбрать Изображение",
|
||||
"Select Video": "Выбрать Видео",
|
||||
"Select Audio": "Выбрать Аудио",
|
||||
"Select Fonts": "Выбрать Шрифты",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "Поддерживаемые форматы шрифтов: .ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -500,5 +500,18 @@
|
||||
"Failed to connect": "ไม่สามารถเชื่อมต่อได้",
|
||||
"Sync Server Connected": "เซิร์ฟเวอร์ซิงค์เชื่อมต่อแล้ว",
|
||||
"Precision: {{precision}} digits after the decimal": "ความแม่นยำ: {{precision}} หลักหลังจุดทศนิยม",
|
||||
"Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}",
|
||||
"Custom Fonts": "ฟอนต์กำหนดเอง",
|
||||
"Cancel Delete": "ยกเลิกการลบ",
|
||||
"Import Font": "นำเข้าฟอนต์",
|
||||
"Delete Font": "ลบฟอนต์",
|
||||
"Tips": "เคล็ดลับ",
|
||||
"Custom fonts can be selected from the Font Face menu": "ฟอนต์กำหนดเองสามารถเลือกได้จากเมนูฟอนต์",
|
||||
"Manage Custom Fonts": "จัดการฟอนต์กำหนดเอง",
|
||||
"Select Files": "เลือกไฟล์",
|
||||
"Select Image": "เลือกรูปภาพ",
|
||||
"Select Video": "เลือกวิดีโอ",
|
||||
"Select Audio": "เลือกเสียง",
|
||||
"Select Fonts": "เลือกฟอนต์",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "รูปแบบฟอนต์ที่รองรับ: .ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -504,5 +504,18 @@
|
||||
"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"
|
||||
"Sync as {{userDisplayName}}": "Senkronize et {{userDisplayName}} olarak",
|
||||
"Custom Fonts": "Özel Yazı Tipleri",
|
||||
"Cancel Delete": "Silmeyi İptal Et",
|
||||
"Import Font": "Yazı Tipi İçe Aktar",
|
||||
"Delete Font": "Yazı Tipini Sil",
|
||||
"Tips": "İpuçları",
|
||||
"Custom fonts can be selected from the Font Face menu": "Özel yazı tipleri Yazı Tipi menüsünden seçilebilir",
|
||||
"Manage Custom Fonts": "Özel Yazı Tiplerini Yönet",
|
||||
"Select Files": "Dosyaları Seç",
|
||||
"Select Image": "Resim Seç",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"Slow": "Повільно",
|
||||
"Solarized": "Сонячний",
|
||||
"Speak": "Озвучити",
|
||||
"Subjects": "Теми",
|
||||
"Subjects": "Жанри",
|
||||
"System Fonts": "Системні шрифти",
|
||||
"Theme Color": "Колір теми",
|
||||
"Theme Mode": "Режим теми",
|
||||
@@ -469,26 +469,26 @@
|
||||
"Disable Double Click": "Вимкнути подвійне натискання",
|
||||
"TTS not supported for this document": "TTS не підтримується в цьому документі",
|
||||
"Reset Password": "Скинути пароль",
|
||||
"Show Reading Progress": "Показати прогрес читання",
|
||||
"Reading Progress Style": "Стиль прогресу читання",
|
||||
"Show Reading Progress": "Показати проґрес читання",
|
||||
"Reading Progress Style": "Стиль проґресу читання",
|
||||
"Page Number": "Номер сторінки",
|
||||
"Percentage": "Процент",
|
||||
"Percentage": "Відсотки",
|
||||
"Deleted local copy of the book: {{title}}": "Видалено локальну копію книги: {{title}}",
|
||||
"Failed to delete cloud backup of the book: {{title}}": "Не вдалося видалити резервну копію книги в хмарі: {{title}}",
|
||||
"Failed to delete local copy of the book: {{title}}": "Не вдалося видалити локальну копію книги: {{title}}",
|
||||
"Are you sure to delete the local copy of the selected book?": "Ви впевнені, що хочете видалити локальну копію вибраної книги?",
|
||||
"Remove from Cloud & Device": "Видалити з хмари та пристрою",
|
||||
"Remove from Cloud Only": "Видалити тільки з хмари",
|
||||
"Remove from Device Only": "Видалити тільки з пристрою",
|
||||
"Disconnected": "Відключено",
|
||||
"Remove from Cloud Only": "Видалити тільки із хмари",
|
||||
"Remove from Device Only": "Видалити тільки із пристрою",
|
||||
"Disconnected": "Від'єднано",
|
||||
"KOReader Sync Settings": "Налаштування синхронізації KOReader",
|
||||
"Sync Strategy": "Стратегія синхронізації",
|
||||
"Ask on conflict": "Запитувати при конфлікті",
|
||||
"Always use latest": "Завжди використовувати останню версію",
|
||||
"Always use latest": "Завжди використовувати найновішу",
|
||||
"Send changes only": "Відправити тільки зміни",
|
||||
"Receive changes only": "Отримати тільки зміни",
|
||||
"Disabled": "Вимкнено",
|
||||
"Checksum Method": "Метод контрольної суми",
|
||||
"Checksum Method": "Метод контролю суми",
|
||||
"File Content (recommended)": "Вміст файлу (рекомендується)",
|
||||
"File Name": "Ім'я файлу",
|
||||
"Device Name": "Ім'я пристрою",
|
||||
@@ -498,19 +498,32 @@
|
||||
"Username": "Ім'я користувача",
|
||||
"Your Username": "Ваше ім'я користувача",
|
||||
"Password": "Пароль",
|
||||
"Connect": "Підключитися",
|
||||
"Connect": "Під'єднатися",
|
||||
"KOReader Sync": "Синхронізація KOReader",
|
||||
"Sync Conflict": "Конфлікт синхронізації",
|
||||
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання з \"{{deviceName}}\"?",
|
||||
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання із \"{{deviceName}}\"?",
|
||||
"another device": "інший пристрій",
|
||||
"Local Progress": "Локальний прогрес",
|
||||
"Remote Progress": "Віддалений прогрес",
|
||||
"Remote Progress": "Прогрес у хмарі",
|
||||
"Page {{page}} of {{total}} ({{percentage}}%)": "Сторінка {{page}} з {{total}} ({{percentage}}%)",
|
||||
"Current position": "Поточна позиція",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} з {{total}} ({{percentage}}%)",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} із {{total}} ({{percentage}}%)",
|
||||
"Approximately {{percentage}}%": "Приблизно {{percentage}}%",
|
||||
"Failed to connect": "Не вдалося підключитися",
|
||||
"Sync Server Connected": "Сервер синхронізації підключено",
|
||||
"Failed to connect": "Не вдалося під'єднатися",
|
||||
"Sync Server Connected": "Сервер синхронізації під'єднано",
|
||||
"Precision: {{precision}} digits after the decimal": "Точність: {{precision}} знаків після коми",
|
||||
"Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}",
|
||||
"Custom Fonts": "Користувацькі Шрифти",
|
||||
"Cancel Delete": "Скасувати Видалення",
|
||||
"Import Font": "Імпортувати Шрифт",
|
||||
"Delete Font": "Видалити Шрифт",
|
||||
"Tips": "Поради",
|
||||
"Custom fonts can be selected from the Font Face menu": "Користувацькі шрифти можна вибрати з меню Шрифт",
|
||||
"Manage Custom Fonts": "Керування Користувацькими Шрифтами",
|
||||
"Select Files": "Вибрати Файли",
|
||||
"Select Image": "Вибрати Зображення",
|
||||
"Select Video": "Вибрати Відео",
|
||||
"Select Audio": "Вибрати Аудіо",
|
||||
"Select Fonts": "Вибрати Шрифти",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "Підтримувані формати шрифтів: .ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -500,5 +500,18 @@
|
||||
"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}}"
|
||||
"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",
|
||||
"Import Font": "Nhập Phông Chữ",
|
||||
"Delete Font": "Xóa Phông Chữ",
|
||||
"Tips": "Mẹo",
|
||||
"Custom fonts can be selected from the Font Face menu": "Phông chữ tùy chỉnh có thể được chọn từ menu Phông Chữ",
|
||||
"Manage Custom Fonts": "Quản Lý Phông Chữ Tùy Chỉnh",
|
||||
"Select Files": "Chọn Tệp",
|
||||
"Select Image": "Chọn Hình Ảnh",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -500,5 +500,18 @@
|
||||
"Failed to connect": "连接失败",
|
||||
"Sync Server Connected": "同步服务器已连接",
|
||||
"Precision: {{precision}} digits after the decimal": "精度:{{precision}} 位小数",
|
||||
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步"
|
||||
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步",
|
||||
"Custom Fonts": "自定义字体",
|
||||
"Cancel Delete": "取消删除",
|
||||
"Import Font": "导入字体",
|
||||
"Delete Font": "删除字体",
|
||||
"Tips": "提示",
|
||||
"Custom fonts can be selected from the Font Face menu": "可以从字形菜单中选择自定义字体",
|
||||
"Manage Custom Fonts": "管理自定义字体",
|
||||
"Select Files": "选择文件",
|
||||
"Select Image": "选择图片",
|
||||
"Select Video": "选择视频",
|
||||
"Select Audio": "选择音频",
|
||||
"Select Fonts": "选择字体",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "支持的字体格式:.ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -500,5 +500,18 @@
|
||||
"Failed to connect": "連接失敗",
|
||||
"Sync Server Connected": "同步伺服器已連接",
|
||||
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 位小數",
|
||||
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步"
|
||||
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步",
|
||||
"Custom Fonts": "自訂字型",
|
||||
"Cancel Delete": "取消刪除",
|
||||
"Import Font": "匯入字型",
|
||||
"Delete Font": "刪除字型",
|
||||
"Tips": "提示",
|
||||
"Custom fonts can be selected from the Font Face menu": "可以從字型選單中選擇自訂字型",
|
||||
"Manage Custom Fonts": "管理自訂字型",
|
||||
"Select Files": "選擇檔案",
|
||||
"Select Image": "選擇圖片",
|
||||
"Select Video": "選擇影片",
|
||||
"Select Audio": "選擇音訊",
|
||||
"Select Fonts": "選擇字型",
|
||||
"Supported font formats: .ttf, .odf, .woff, .woff2": "支援的字型格式:.ttf, .odf, .woff, .woff2"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.75": {
|
||||
"date": "2025-08-22",
|
||||
"notes": [
|
||||
"Fonts: Added support for importing local TTF and ODF fonts",
|
||||
"TTS: Reduced the pause between sentences when using Edge TTS",
|
||||
"TTS: Fixed an issue where voices list could not be opened on older versions of iOS",
|
||||
"TOC: Fixed an issue where nested TOC items would not collapse properly in some books",
|
||||
"Translator: Fixed an issue where translations in the popup did not refresh correctly",
|
||||
"Library: Added option to sort authors with last name first",
|
||||
"Layout: Fixed mismatch in close button position between the reader and library pages",
|
||||
"Layout: Book title now remains centered in the available space",
|
||||
"Layout: Fixed footer bar layout in landscape mode"
|
||||
]
|
||||
},
|
||||
"0.9.72": {
|
||||
"date": "2025-08-18",
|
||||
"notes": [
|
||||
"Fixed Edge TTS voice playback for EPUBs",
|
||||
"Reduced accidental page flips when toggling toolbars",
|
||||
"Fixed images with background colors not displaying correctly in some books",
|
||||
"Added support for custom KOReader Sync Servers on your local network (LAN)"
|
||||
]
|
||||
},
|
||||
"0.9.71": {
|
||||
"date": "2025-08-13",
|
||||
"notes": [
|
||||
"Sync: Added two ways to sync reading progress with Koreader devices",
|
||||
"Sync: Added two ways to sync reading progress with KOReader devices",
|
||||
"EPUB: Applied monospace font settings",
|
||||
"EPUB: Fixed initial text alignment for some EPUB files",
|
||||
"PDF: Fixed issue opening large PDF files on Android",
|
||||
|
||||
@@ -41,7 +41,7 @@ tauri-plugin-log = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-http = { version = "2", features = ["dangerous-settings"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-oauth = "2"
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
},
|
||||
{
|
||||
"url": "https://translate.googleapis.com"
|
||||
},
|
||||
{
|
||||
"url": "http://*:*"
|
||||
},
|
||||
{
|
||||
"url": "https://*:*"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<intent-filter android:autoVerify="true" >
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
@@ -239,8 +239,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const bTitle = formatTitle(b.title);
|
||||
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
|
||||
case 'author':
|
||||
const aAuthors = formatAuthors(a.author);
|
||||
const bAuthors = formatAuthors(b.author);
|
||||
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
|
||||
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
|
||||
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
|
||||
case 'updated':
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getOSPlatform } from '@/utils/misc';
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type SelectProps = {
|
||||
@@ -43,8 +44,8 @@ const StyledSelect: React.FC<SelectProps> = ({
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{options.map(({ value, label, disabled = false }) => (
|
||||
<option key={value} value={value} disabled={disabled}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
@@ -83,6 +84,13 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
|
||||
// Get the OS name once
|
||||
useEffect(() => {
|
||||
const formatOsName = (name: string): string => {
|
||||
if (!name) return '';
|
||||
if (name.toLowerCase() === 'macos') return 'macOS';
|
||||
if (name.toLowerCase() === 'ios') return 'iOS';
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
|
||||
const getOsName = async () => {
|
||||
let name = '';
|
||||
if (appService?.appPlatform === 'tauri') {
|
||||
@@ -93,7 +101,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
name = platform;
|
||||
}
|
||||
}
|
||||
setOsName(name ? name.charAt(0).toUpperCase() + name.slice(1) : '');
|
||||
setOsName(formatOsName(name));
|
||||
};
|
||||
getOsName();
|
||||
}, [appService]);
|
||||
@@ -236,7 +244,6 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={_('KOReader Sync Settings')}
|
||||
boxClassName='sm:!min-w-[520px] sm:h-auto'
|
||||
bgClassName='!bg-black/60'
|
||||
>
|
||||
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
|
||||
{isConfigured ? (
|
||||
@@ -284,7 +291,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
onChange={handleChecksumMethodChange}
|
||||
options={[
|
||||
{ value: 'binary', label: _('File Content (recommended)') },
|
||||
{ value: 'filename', label: _('File Name') },
|
||||
{ value: 'filename', label: _('File Name'), disabled: true },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -335,34 +342,40 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
type='text'
|
||||
placeholder='https://koreader.sync.server'
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
spellCheck='false'
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Username')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={_('Your Username')}
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Password')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder={_('Your Password')}
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<form className='flex flex-col gap-4'>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Username')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={_('Your Username')}
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
spellCheck='false'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete='username'
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Password')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder={_('Your Password')}
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete='current-password'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<button
|
||||
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
|
||||
onClick={handleConnect}
|
||||
|
||||
@@ -110,7 +110,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
|
||||
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px]',
|
||||
windowButtonVisible ? 'sm:pr-4' : 'sm:pr-6',
|
||||
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -10,20 +10,15 @@ import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService, DeleteAction } from '@/types/system';
|
||||
import { navigateToLogin, navigateToReader } from '@/utils/nav';
|
||||
import {
|
||||
formatAuthors,
|
||||
formatTitle,
|
||||
getFilename,
|
||||
getPrimaryLanguage,
|
||||
listFormater,
|
||||
} from '@/utils/book';
|
||||
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { ProgressPayload } from '@/utils/transfer';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { getFilename } from '@/utils/path';
|
||||
import { parseOpenWithFiles } from '@/helpers/openWith';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
|
||||
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
||||
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
|
||||
@@ -153,12 +148,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
} else {
|
||||
fileExt = file.name.split('.').pop()?.toLowerCase();
|
||||
}
|
||||
return FILE_ACCEPT_FORMATS.includes(`.${fileExt}`);
|
||||
return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`);
|
||||
});
|
||||
if (supportedFiles.length === 0) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('No supported files found. Supported formats: {{formats}}', {
|
||||
formats: FILE_ACCEPT_FORMATS,
|
||||
formats: BOOK_ACCEPT_FORMATS,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
@@ -437,12 +432,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
};
|
||||
|
||||
const selectFilesTauri = async () => {
|
||||
const exts = appService?.isIOSApp ? [] : SUPPORTED_FILE_EXTS;
|
||||
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_FILE_EXTS.includes(fileExt);
|
||||
return SUPPORTED_BOOK_EXTS.includes(fileExt);
|
||||
});
|
||||
}
|
||||
return files;
|
||||
@@ -452,7 +447,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = FILE_ACCEPT_FORMATS;
|
||||
fileInput.accept = BOOK_ACCEPT_FORMATS;
|
||||
fileInput.multiple = true;
|
||||
fileInput.click();
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useCustomFontStore } from '@/store/customFontStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
@@ -21,7 +23,7 @@ import {
|
||||
getStyles,
|
||||
transformStylesheet,
|
||||
} from '@/utils/style';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
import {
|
||||
@@ -57,12 +59,14 @@ const FoliateViewer: React.FC<{
|
||||
config: BookConfig;
|
||||
contentInsets: Insets;
|
||||
}> = ({ bookKey, bookDoc, config, contentInsets: insets }) => {
|
||||
const { appService, envConfig } = useEnv();
|
||||
const { themeCode, isDarkMode } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { loadCustomFonts, getLoadedFonts } = useCustomFontStore();
|
||||
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { appService } = useEnv();
|
||||
const { themeCode, isDarkMode } = useThemeStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
@@ -79,12 +83,8 @@ const FoliateViewer: React.FC<{
|
||||
useUICSS(bookKey);
|
||||
useProgressSync(bookKey);
|
||||
useProgressAutoSave(bookKey);
|
||||
const {
|
||||
syncState,
|
||||
conflictDetails,
|
||||
resolveConflictWithLocal,
|
||||
resolveConflictWithRemote,
|
||||
} = useKOSync(bookKey);
|
||||
const { syncState, conflictDetails, resolveConflictWithLocal, resolveConflictWithRemote } =
|
||||
useKOSync(bookKey);
|
||||
useTextTranslation(bookKey, viewRef.current);
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
@@ -144,6 +144,10 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
|
||||
|
||||
getLoadedFonts().forEach((font) => {
|
||||
mountCustomFont(detail.doc, font);
|
||||
});
|
||||
|
||||
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
||||
applyFixedlayoutStyles(detail.doc, viewSettings);
|
||||
}
|
||||
@@ -335,6 +339,21 @@ const FoliateViewer: React.FC<{
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeCode, isDarkMode, viewSettings?.overrideColor, viewSettings?.invertImgColorInDark]);
|
||||
|
||||
useEffect(() => {
|
||||
const mountCustomFonts = async () => {
|
||||
await loadCustomFonts(envConfig);
|
||||
getLoadedFonts().forEach((font) => {
|
||||
mountCustomFont(document, font);
|
||||
const docs = viewRef.current?.renderer.getContents();
|
||||
docs?.forEach(({ doc }) => mountCustomFont(doc, font));
|
||||
});
|
||||
};
|
||||
if (settings.customFonts) {
|
||||
mountCustomFonts();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.customFonts, envConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.renderer) {
|
||||
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
|
||||
|
||||
@@ -187,7 +187,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
'sm:h-[52px] sm:justify-center',
|
||||
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
|
||||
'transition-[opacity,transform] duration-300',
|
||||
appService?.isMobile ? 'fixed' : 'absolute',
|
||||
// See: https://github.com/readest/readest/issues/1716
|
||||
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
@@ -366,7 +367,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
</span>
|
||||
<input
|
||||
type='range'
|
||||
className='text-base-content mx-2 w-full'
|
||||
className='text-base-content mx-2 min-w-0 flex-1'
|
||||
min={0}
|
||||
max={100}
|
||||
value={progressValid ? progressFraction * 100 : 0}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { mountAdditionalFonts } from '@/styles/fonts';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { isCJKLang } from '@/utils/lang';
|
||||
|
||||
@@ -40,6 +40,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
trafficLightInFullscreen,
|
||||
setTrafficLightVisibility,
|
||||
initializeTrafficLightStore,
|
||||
@@ -52,6 +53,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
|
||||
const handleToggleDropdown = (isOpen: boolean) => {
|
||||
setIsDropdownOpen(isOpen);
|
||||
if (!isOpen) setHoveredBookKey('');
|
||||
@@ -133,9 +136,19 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
</div>
|
||||
|
||||
<div className='header-title z-15 bg-base-100 pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
|
||||
<h2 className='line-clamp-1 max-w-[50%] text-center text-xs font-semibold'>
|
||||
{bookTitle}
|
||||
<div
|
||||
className={clsx(
|
||||
'header-title z-15 bg-base-100 pointer-events-none hidden flex-1 items-center justify-center sm:flex',
|
||||
!windowButtonVisible && 'absolute inset-0',
|
||||
)}
|
||||
>
|
||||
<h2
|
||||
className={clsx(
|
||||
'line-clamp-1 text-center text-xs font-semibold',
|
||||
!windowButtonVisible && 'max-w-[50%]',
|
||||
)}
|
||||
>
|
||||
{bookTitle} {bookTitle} {bookTitle} {bookTitle}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -154,16 +167,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<WindowButtons
|
||||
className='window-buttons flex h-full items-center'
|
||||
headerRef={headerRef}
|
||||
showMinimize={
|
||||
bookKeys.length == 1 &&
|
||||
!appService?.hasTrafficLight &&
|
||||
appService?.appPlatform !== 'web'
|
||||
}
|
||||
showMaximize={
|
||||
bookKeys.length == 1 &&
|
||||
!appService?.hasTrafficLight &&
|
||||
appService?.appPlatform !== 'web'
|
||||
}
|
||||
showMinimize={bookKeys.length == 1 && windowButtonVisible}
|
||||
showMaximize={bookKeys.length == 1 && windowButtonVisible}
|
||||
onClose={() => {
|
||||
setHoveredBookKey(null);
|
||||
onCloseBook(bookKey);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { interceptWindowOpen } from '@/utils/open';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { mountAdditionalFonts } from '@/styles/fonts';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
|
||||
@@ -129,7 +129,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
|
||||
fetchTranslation();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [text, token, sourceLang, targetLang, provider]);
|
||||
}, [text, token, sourceLang, targetLang, provider, translate]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -176,9 +176,12 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
className='bg-gray-600 text-white/75'
|
||||
value={targetLang}
|
||||
onChange={handleTargetLangChange}
|
||||
options={Object.entries(translatorLangs)
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([code, name]) => ({ value: code, label: name }))}
|
||||
options={[
|
||||
{ value: '', label: _('System Language') },
|
||||
...Object.entries(translatorLangs)
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([code, name]) => ({ value: code, label: name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
|
||||
@@ -187,7 +187,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Color')}</h2>
|
||||
<h2 className='font-medium'>{_('Override Book Color')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
@@ -264,7 +264,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
onChange={(event) => setCodeLanguage(event.target.value as CodeLanguage)}
|
||||
options={CODE_LANGUAGES.map((lang) => ({
|
||||
value: lang,
|
||||
label: lang,
|
||||
label: lang === 'auto-detect' ? _('Auto') : lang,
|
||||
}))}
|
||||
disabled={!codeHighlighting}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { MdAdd, MdDelete } from 'react-icons/md';
|
||||
import { IoMdCloseCircleOutline } from 'react-icons/io';
|
||||
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 { mountCustomFont } from '@/styles/fonts';
|
||||
import { parseFontFamily } from '@/utils/font';
|
||||
import { getFilename } from '@/utils/path';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
|
||||
interface CustomFontsProps {
|
||||
bookKey: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService, envConfig } = useEnv();
|
||||
const {
|
||||
fonts: customFonts,
|
||||
addFont,
|
||||
loadFont,
|
||||
removeFont,
|
||||
getAvailableFonts,
|
||||
saveCustomFonts,
|
||||
} = useCustomFontStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const [isDeleteMode, setIsDeleteMode] = useState(false);
|
||||
|
||||
const { selectFiles } = useFileSelector(appService, _);
|
||||
|
||||
const currentDefaultFont =
|
||||
viewSettings.defaultFont.toLowerCase() === 'serif' ? 'serif' : 'sans-serif';
|
||||
|
||||
const currentFontFamily =
|
||||
currentDefaultFont === 'serif' ? viewSettings.serifFont : viewSettings.sansSerifFont;
|
||||
|
||||
const handleImportFont = () => {
|
||||
selectFiles({ ...FILE_SELECTION_PRESETS.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');
|
||||
}
|
||||
for (const selectedFile of result.files) {
|
||||
let fontPath: string;
|
||||
let fontFile: File;
|
||||
if (selectedFile.path) {
|
||||
const filePath = selectedFile.path;
|
||||
fontPath = getFilename(filePath);
|
||||
await appService!.fs.copyFile(filePath, fontPath, 'Fonts');
|
||||
fontFile = await appService!.fs.openFile(fontPath, 'Fonts');
|
||||
} else if (selectedFile.file) {
|
||||
const file = selectedFile.file;
|
||||
fontPath = getFilename(file.name);
|
||||
await appService!.fs.writeFile(fontPath, 'Fonts', file);
|
||||
fontFile = file;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const fontFamily = parseFontFamily(await fontFile.arrayBuffer(), fontPath);
|
||||
const customFont = addFont(fontPath, {
|
||||
name: fontFamily,
|
||||
});
|
||||
if (customFont && !customFont.error) {
|
||||
const loadedFont = await loadFont(envConfig, customFont.id);
|
||||
mountCustomFont(document, loadedFont);
|
||||
}
|
||||
}
|
||||
saveCustomFonts(envConfig);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteFont = (fontId: string) => {
|
||||
const font = customFonts.find((f) => f.id === fontId);
|
||||
if (font) {
|
||||
if (removeFont(fontId)) {
|
||||
appService!.fs.removeFile(font.path, 'Fonts');
|
||||
saveCustomFonts(envConfig);
|
||||
if (getAvailableFonts().length === 0) {
|
||||
setIsDeleteMode(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFont = (fontId: string) => {
|
||||
const font = customFonts.find((f) => f.id === fontId);
|
||||
if (font) {
|
||||
if (currentDefaultFont === 'serif') {
|
||||
saveViewSettings(envConfig, bookKey, 'serifFont', font.name);
|
||||
} else {
|
||||
saveViewSettings(envConfig, bookKey, 'sansSerifFont', font.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleteMode = () => {
|
||||
setIsDeleteMode(!isDeleteMode);
|
||||
};
|
||||
|
||||
const availableFonts = customFonts
|
||||
.filter((font) => !font.deletedAt)
|
||||
.sort((a, b) => (b.downloadedAt || 0) - (a.downloadedAt || 0));
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-6 flex h-8 items-center justify-between'>
|
||||
<div className='breadcrumbs py-1'>
|
||||
<ul>
|
||||
<li>
|
||||
<a className='font-semibold' onClick={onBack}>
|
||||
{_('Font')}
|
||||
</a>
|
||||
</li>
|
||||
<li className='font-medium'>{_('Custom Fonts')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{availableFonts.length > 0 && (
|
||||
<button
|
||||
onClick={toggleDeleteMode}
|
||||
className={`btn btn-ghost btn-sm text-base-content gap-2`}
|
||||
title={isDeleteMode ? _('Cancel Delete') : _('Delete Font')}
|
||||
>
|
||||
{isDeleteMode ? (
|
||||
<>{_('Cancel')}</>
|
||||
) : (
|
||||
<>
|
||||
<MdDelete className='h-4 w-4' />
|
||||
{_('Delete')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='card border-primary/50 hover:border-primary/75 group h-12 border-2 transition-colors'>
|
||||
<div
|
||||
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
|
||||
onClick={handleImportFont}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<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'>
|
||||
{_('Import Font')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableFonts.map((font) => (
|
||||
<div
|
||||
key={font.id}
|
||||
className={clsx(
|
||||
'card h-12 border shadow-sm',
|
||||
currentFontFamily === font.name
|
||||
? 'border-primary/50 bg-primary/50'
|
||||
: 'border-base-200 bg-base-200 cursor-pointer',
|
||||
)}
|
||||
onClick={() => handleSelectFont(font.id)}
|
||||
>
|
||||
<div className='card-body flex items-center justify-center p-2'>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.loaded ? `"${font.name}", sans-serif` : 'sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
className='text-base-content line-clamp-1'
|
||||
>
|
||||
{font.name}
|
||||
</div>
|
||||
{isDeleteMode && (
|
||||
<button
|
||||
onClick={() => handleDeleteFont(font.id)}
|
||||
className='btn btn-ghost btn-xs absolute right-[-10px] top-[-10px] h-6 min-h-0 w-6 p-0 hover:bg-transparent'
|
||||
title={_('Delete Font')}
|
||||
>
|
||||
<IoMdCloseCircleOutline className='text-base-content/75 h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='bg-base-200/30 my-8 rounded-lg p-4'>
|
||||
<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>{_('Custom fonts can be selected from the Font Face menu')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomFonts;
|
||||
@@ -14,10 +14,16 @@ interface DialogMenuProps {
|
||||
resetLabel?: string;
|
||||
}
|
||||
|
||||
const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, resetLabel }) => {
|
||||
const DialogMenu: React.FC<DialogMenuProps> = ({
|
||||
activePanel,
|
||||
setIsDropdownOpen,
|
||||
onReset,
|
||||
resetLabel,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const iconSize = useResponsiveSize(16);
|
||||
const { isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } = useSettingsStore();
|
||||
const { setFontPanelView, isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } =
|
||||
useSettingsStore();
|
||||
|
||||
const handleToggleGlobal = () => {
|
||||
setFontLayoutSettingsGlobal(!isFontLayoutSettingsGlobal);
|
||||
@@ -29,6 +35,11 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, res
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleManageCustomFont = () => {
|
||||
setFontPanelView('custom-fonts');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
@@ -49,6 +60,9 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, res
|
||||
onClick={handleToggleGlobal}
|
||||
/>
|
||||
<MenuItem label={resetLabel || _('Reset Settings')} onClick={handleResetToDefaults} />
|
||||
{activePanel === 'Font' && (
|
||||
<MenuItem label={_('Manage Custom Fonts')} onClick={handleManageCustomFont} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,12 +34,7 @@ const FontItem: React.FC<FontItemProps> = ({ index, style, data }) => {
|
||||
const option = options[index]!;
|
||||
|
||||
return (
|
||||
<li
|
||||
className='px-1 sm:px-2'
|
||||
key={option.option}
|
||||
style={style}
|
||||
onClick={() => onSelect(option.option)}
|
||||
>
|
||||
<li className='px-2' key={option.option} style={style} onClick={() => onSelect(option.option)}>
|
||||
<div className='flex w-full items-center overflow-hidden !px-0 text-sm'>
|
||||
<span style={{ minWidth: `${iconSize16}px` }}>
|
||||
{selected === option.option && (
|
||||
@@ -138,7 +133,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
|
||||
{/* More options with nested dropdown */}
|
||||
{moreOptions && moreOptions.length > 0 && (
|
||||
<li className='dropdown dropdown-left dropdown-top px-1 sm:px-2'>
|
||||
<li className='dropdown dropdown-left dropdown-top px-2'>
|
||||
<div className='flex items-center px-0 text-sm'>
|
||||
<span style={{ minWidth: `${iconSize}px` }}>
|
||||
<FiChevronLeft size={iconSize} />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MdSettings } from 'react-icons/md';
|
||||
|
||||
import {
|
||||
ANDROID_FONTS,
|
||||
CJK_EXCLUDE_PATTENS,
|
||||
CJK_FONTS_PATTENS,
|
||||
CJK_NAMES_PATTENS,
|
||||
CJK_SANS_SERIF_FONTS,
|
||||
CJK_SERIF_FONTS,
|
||||
IOS_FONTS,
|
||||
@@ -17,27 +17,33 @@ import {
|
||||
SERIF_FONTS,
|
||||
WINDOWS_FONTS,
|
||||
} from '@/services/constants';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useCustomFontStore } from '@/store/customFontStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
|
||||
import { getSysFontsList } from '@/utils/bridge';
|
||||
import { isCJKStr } from '@/utils/lang';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { useResetViewSettings } from '../../hooks/useResetSettings';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import NumberInput from './NumberInput';
|
||||
import FontDropdown from './FontDropDown';
|
||||
import CustomFonts from './CustomFonts';
|
||||
|
||||
const genCJKFontsList = (sysFonts: string[]) => {
|
||||
return Array.from(new Set([...sysFonts, ...CJK_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]))
|
||||
.filter((font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font))
|
||||
.filter((font) => CJK_FONTS_PATTENS.test(font) || isCJKStr(font))
|
||||
.filter((font) => !CJK_EXCLUDE_PATTENS.test(font))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const isSymbolicFontName = (font: string) =>
|
||||
/emoji|icons|symbol|dingbats|ornaments|webdings|wingdings|miuiex/i.test(font);
|
||||
|
||||
interface FontFaceProps {
|
||||
className?: string;
|
||||
family: string;
|
||||
@@ -85,8 +91,11 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { fontPanelView, setFontPanelView } = useSettingsStore();
|
||||
const { fonts: allCustomFonts, getFontFamilies } = useCustomFontStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const view = getView(bookKey)!;
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
|
||||
const fontFamilyOptions = [
|
||||
{
|
||||
@@ -130,8 +139,10 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
|
||||
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
|
||||
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
|
||||
|
||||
const [customFonts, setCustomFonts] = useState<string[]>(getFontFamilies());
|
||||
const [CJKFonts, setCJKFonts] = useState<string[]>(() => {
|
||||
return genCJKFontsList(sysFonts);
|
||||
return genCJKFontsList([...customFonts, ...sysFonts]);
|
||||
});
|
||||
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
@@ -150,6 +161,14 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
});
|
||||
};
|
||||
|
||||
const handleManageCustomFonts = () => {
|
||||
setFontPanelView('custom-fonts');
|
||||
};
|
||||
|
||||
const handleBackToMain = () => {
|
||||
setFontPanelView('main-fonts');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterReset(handleReset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -157,10 +176,20 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
|
||||
useEffect(() => {
|
||||
setCJKFonts((prev) => {
|
||||
const newFonts = genCJKFontsList(sysFonts);
|
||||
const newFonts = genCJKFontsList([...customFonts, ...sysFonts]);
|
||||
return prev.length !== newFonts.length ? newFonts : prev;
|
||||
});
|
||||
}, [sysFonts]);
|
||||
}, [customFonts, sysFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomFonts(getFontFamilies());
|
||||
}, [allCustomFonts, getFontFamilies]);
|
||||
|
||||
useEffect(() => {
|
||||
setSerifFont(viewSettings.serifFont);
|
||||
setSansSerifFont(viewSettings.sansSerifFont);
|
||||
setMonospaceFont(viewSettings.monospaceFont);
|
||||
}, [viewSettings.serifFont, viewSettings.sansSerifFont, viewSettings.monospaceFont]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauriAppPlatform()) {
|
||||
@@ -247,10 +276,18 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
}
|
||||
};
|
||||
|
||||
if (fontPanelView === 'custom-fonts') {
|
||||
return (
|
||||
<div className='my-4 w-full'>
|
||||
<CustomFonts bookKey={bookKey} onBack={handleBackToMain} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Font')}</h2>
|
||||
<h2 className='font-medium'>{_('Override Book Font')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
@@ -326,14 +363,27 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Font Face')}</h2>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Font Face')}</h2>
|
||||
<button
|
||||
onClick={handleManageCustomFonts}
|
||||
className='btn btn-ghost btn-xs gap-1 hover:bg-transparent'
|
||||
title={_('Manage Custom Fonts')}
|
||||
>
|
||||
<MdSettings size={iconSize18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className='card border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<FontFace
|
||||
className='config-item-top'
|
||||
family='serif'
|
||||
label={_('Serif Font')}
|
||||
options={[...SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SERIF_FONTS]}
|
||||
options={[
|
||||
...customFonts,
|
||||
...SERIF_FONTS.filter(filterNonFreeFonts),
|
||||
...CJK_SERIF_FONTS,
|
||||
]}
|
||||
moreOptions={sysFonts}
|
||||
selected={serifFont}
|
||||
onSelect={setSerifFont}
|
||||
@@ -341,7 +391,11 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
<FontFace
|
||||
family='sans-serif'
|
||||
label={_('Sans-Serif Font')}
|
||||
options={[...SANS_SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SANS_SERIF_FONTS]}
|
||||
options={[
|
||||
...customFonts,
|
||||
...SANS_SERIF_FONTS.filter(filterNonFreeFonts),
|
||||
...CJK_SANS_SERIF_FONTS,
|
||||
]}
|
||||
moreOptions={sysFonts}
|
||||
selected={sansSerifFont}
|
||||
onSelect={setSansSerifFont}
|
||||
@@ -350,7 +404,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
className='config-item-bottom'
|
||||
family='monospace'
|
||||
label={_('Monospace Font')}
|
||||
options={MONOSPACE_FONTS}
|
||||
options={[...customFonts, ...MONOSPACE_FONTS]}
|
||||
moreOptions={sysFonts}
|
||||
selected={monospaceFont}
|
||||
onSelect={setMonospaceFont}
|
||||
|
||||
@@ -370,7 +370,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Layout')}</h2>
|
||||
<h2 className='font-medium'>{_('Override Book Layout')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
|
||||
@@ -40,7 +40,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
|
||||
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
const { setFontPanelView, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
@@ -85,6 +85,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
|
||||
const handleSetActivePanel = (tab: SettingsPanelType) => {
|
||||
setActivePanel(tab);
|
||||
setFontPanelView('main-fonts');
|
||||
localStorage.setItem('lastConfigPanel', tab);
|
||||
};
|
||||
|
||||
@@ -115,6 +116,8 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFontPanelView('main-fonts');
|
||||
|
||||
const container = tabsRef.current;
|
||||
if (!container) return;
|
||||
|
||||
@@ -163,6 +166,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
bgClassName='sm:!bg-black/20'
|
||||
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
|
||||
snapHeight={appService?.isMobile ? 0.7 : undefined}
|
||||
header={
|
||||
|
||||
@@ -93,6 +93,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
<ul className='max-h-60 overflow-y-auto'>
|
||||
{getVisibleLibrary()
|
||||
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
|
||||
.filter((book) => !!book.downloadedAt)
|
||||
.slice(0, 20)
|
||||
.map((book) => (
|
||||
<MenuItem
|
||||
|
||||
@@ -13,12 +13,17 @@ import { getContentMd5 } from '@/utils/misc';
|
||||
import { useTextTranslation } from '../../hooks/useTextTranslation';
|
||||
import { FlatTOCItem, StaticListRow, VirtualListRow } from './TOCItem';
|
||||
|
||||
const getItemIdentifier = (item: TOCItem) => {
|
||||
const href = item.href || '';
|
||||
return `toc-item-${item.id}-${href}`;
|
||||
};
|
||||
|
||||
const useFlattenedTOC = (toc: TOCItem[], expandedItems: Set<string>) => {
|
||||
return useMemo(() => {
|
||||
const flattenTOC = (items: TOCItem[], depth = 0): FlatTOCItem[] => {
|
||||
const result: FlatTOCItem[] = [];
|
||||
items.forEach((item, index) => {
|
||||
const isExpanded = expandedItems.has(item.href || '');
|
||||
const isExpanded = expandedItems.has(getItemIdentifier(item));
|
||||
result.push({ item, depth, index, isExpanded });
|
||||
if (item.subitems && isExpanded) {
|
||||
result.push(...flattenTOC(item.subitems, depth + 1));
|
||||
@@ -126,13 +131,13 @@ const TOCView: React.FC<{
|
||||
}, [flatItems, activeHref]);
|
||||
|
||||
const handleToggleExpand = useCallback((item: TOCItem) => {
|
||||
const href = item.href || '';
|
||||
const itemId = getItemIdentifier(item);
|
||||
setExpandedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(href)) {
|
||||
newSet.delete(href);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(href);
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
@@ -149,9 +154,10 @@ const TOCView: React.FC<{
|
||||
);
|
||||
|
||||
const expandParents = useCallback((toc: TOCItem[], href: string) => {
|
||||
const parentPath = findParentPath(toc, href).map((item) => item.href);
|
||||
const parentHrefs = parentPath.filter(Boolean) as string[];
|
||||
setExpandedItems(new Set(parentHrefs));
|
||||
const parentItems = findParentPath(toc, href)
|
||||
.map((item) => getItemIdentifier(item))
|
||||
.filter(Boolean);
|
||||
setExpandedItems(new Set(parentItems));
|
||||
}, []);
|
||||
|
||||
const scrollToActiveItem = useCallback(() => {
|
||||
@@ -176,7 +182,8 @@ const TOCView: React.FC<{
|
||||
(activeItem as HTMLElement).setAttribute('aria-current', 'page');
|
||||
}
|
||||
}
|
||||
}, [activeHref, flatItems]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeHref]);
|
||||
|
||||
const virtualItemSize = useMemo(() => {
|
||||
return window.innerWidth >= 640 && !viewSettings?.translationEnabled ? 37 : 57;
|
||||
@@ -210,7 +217,7 @@ const TOCView: React.FC<{
|
||||
setTimeout(scrollToActiveItem, appService?.isAndroidApp ? 300 : 0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flatItems, scrollToActiveItem]);
|
||||
}, [scrollToActiveItem]);
|
||||
|
||||
return flatItems.length > 256 ? (
|
||||
<div
|
||||
|
||||
@@ -13,14 +13,18 @@ const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative h-full w-full',
|
||||
'relative h-full w-full rounded-full',
|
||||
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
)}
|
||||
style={{
|
||||
maskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
|
||||
WebkitMaskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
|
||||
<div
|
||||
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
|
||||
className='absolute -inset-full rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
|
||||
style={{
|
||||
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
|
||||
}}
|
||||
|
||||
@@ -248,6 +248,7 @@ const TTSPanel = ({
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='flex flex-col items-center justify-center rounded-full p-1'
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<MdAlarm size={iconSize32} />
|
||||
{timeoutCountdown && (
|
||||
@@ -290,7 +291,11 @@ const TTSPanel = ({
|
||||
</div>
|
||||
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button tabIndex={0} className='rounded-full p-1'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='rounded-full p-1'
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<RiVoiceAiFill size={iconSize32} />
|
||||
</button>
|
||||
<ul
|
||||
|
||||
@@ -59,7 +59,7 @@ export const useProgressSync = (bookKey: string) => {
|
||||
config.xpointer = xpointerResult.xpointer;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to convert CFI to XPointer', error);
|
||||
console.warn('Failed to convert CFI to XPointer', error);
|
||||
}
|
||||
pushConfig(bookKey, config);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20',
|
||||
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/50',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
bgClassName,
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function Select({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
|
||||
'select h-8 min-h-8 rounded-md border-none text-sm',
|
||||
'bg-base-200 focus:outline-none focus:ring-0',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -187,7 +187,6 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
||||
title={editMode ? _('Edit Metadata') : _('Book Details')}
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
bgClassName='sm:bg-black/50'
|
||||
boxClassName={clsx(
|
||||
editMode ? 'sm:min-w-[600px] sm:max-w-[600px]' : 'sm:min-w-[480px] sm:max-w-[480px]',
|
||||
'sm:h-auto sm:max-h-[90%]',
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { AppService } from '@/types/system';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
|
||||
|
||||
export interface FileSelectorOptions {
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
extensions?: string[];
|
||||
dialogTitle?: string;
|
||||
}
|
||||
|
||||
export interface SelectedFile {
|
||||
// For Web file
|
||||
file?: File;
|
||||
|
||||
// For Tauri file
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface FileSelectionResult {
|
||||
files: SelectedFile[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const selectFileWeb = (options: FileSelectorOptions): Promise<File[]> => {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = options.accept || '*/*';
|
||||
fileInput.multiple = options.multiple || false;
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = () => {
|
||||
resolve(Array.from(fileInput.files || []));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const selectFileTauri = async (
|
||||
options: FileSelectorOptions,
|
||||
appService: AppService,
|
||||
_: (key: string) => string,
|
||||
): Promise<string[]> => {
|
||||
const exts = appService?.isIOSApp ? [] : options.extensions || [];
|
||||
const title = options.dialogTitle || _('Select Files');
|
||||
const 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);
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const processWebFiles = (files: File[]): SelectedFile[] => {
|
||||
return files.map((file) => ({
|
||||
file,
|
||||
}));
|
||||
};
|
||||
|
||||
const processTauriFiles = (files: string[]): SelectedFile[] => {
|
||||
return files.map((path) => ({
|
||||
path,
|
||||
}));
|
||||
};
|
||||
|
||||
export const useFileSelector = (appService: AppService | null, _: (key: string) => string) => {
|
||||
const selectFiles = async (options: FileSelectorOptions = {}) => {
|
||||
if (!appService) {
|
||||
return { files: [], error: 'App service is not available' };
|
||||
}
|
||||
try {
|
||||
if (isTauriAppPlatform()) {
|
||||
const filePaths = await selectFileTauri(options, appService, _);
|
||||
const files = await processTauriFiles(filePaths);
|
||||
return { files };
|
||||
} else {
|
||||
const webFiles = await selectFileWeb(options);
|
||||
const files = processWebFiles(webFiles);
|
||||
return { files };
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
files: [],
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
return {
|
||||
selectFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export const FILE_SELECTION_PRESETS = {
|
||||
images: {
|
||||
accept: 'image/*',
|
||||
extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
|
||||
dialogTitle: _('Select Image'),
|
||||
},
|
||||
videos: {
|
||||
accept: 'video/*',
|
||||
extensions: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'],
|
||||
dialogTitle: _('Select Video'),
|
||||
},
|
||||
audio: {
|
||||
accept: 'audio/*',
|
||||
extensions: ['mp3', 'wav', 'ogg', 'flac', 'm4a'],
|
||||
dialogTitle: _('Select Audio'),
|
||||
},
|
||||
books: {
|
||||
accept: BOOK_ACCEPT_FORMATS,
|
||||
extensions: SUPPORTED_BOOK_EXTS,
|
||||
dialogTitle: _('Select Books'),
|
||||
},
|
||||
fonts: {
|
||||
accept: '.ttf, .otf, .woff, .woff2',
|
||||
extensions: ['ttf', 'otf', 'woff', 'woff2'],
|
||||
dialogTitle: _('Select Fonts'),
|
||||
},
|
||||
};
|
||||
@@ -94,7 +94,7 @@ export const useTheme = ({
|
||||
applyCustomTheme(customTheme);
|
||||
});
|
||||
localStorage.setItem('customThemes', JSON.stringify(customThemes));
|
||||
}, [settings]);
|
||||
}, [settings.globalReadSettings?.customThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
const colorScheme = isDarkMode ? 'dark' : 'light';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { md5 } from 'js-md5';
|
||||
import { createHash } from 'crypto';
|
||||
import { randomMd5 } from '@/utils/misc';
|
||||
import { LRUCache } from '@/utils/lru';
|
||||
import { genSSML } from '@/utils/ssml';
|
||||
@@ -6,6 +7,7 @@ import { genSSML } from '@/utils/ssml';
|
||||
const EDGE_SPEECH_URL =
|
||||
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
|
||||
const EDGE_API_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
||||
const CHROMIUM_FULL_VERSION = '130.0.2849.68';
|
||||
const EDGE_TTS_VOICES = {
|
||||
'af-ZA': ['af-ZA-AdriNeural', 'af-ZA-WillemNeural'],
|
||||
'am-ET': ['am-ET-AmehaNeural', 'am-ET-MekdesNeural'],
|
||||
@@ -120,6 +122,31 @@ const EDGE_TTS_VOICES = {
|
||||
'zh-TW': ['zh-TW-HsiaoChenNeural', 'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the Sec-MS-GEC token value.
|
||||
* This function generates a token value based on the current time in Windows file time format
|
||||
* adjusted for clock skew, and rounded down to the nearest 5 minutes. The token is then hashed
|
||||
* using SHA256 and returned as an uppercased hex digest.
|
||||
*
|
||||
* @returns The generated Sec-MS-GEC token value.
|
||||
* @see https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570
|
||||
*/
|
||||
const WIN_EPOCH_OFFSET = 11644473600; // Windows epoch offset in seconds (1601 to 1970)
|
||||
const S_TO_NS = 1000000000; // Seconds to nanoseconds conversion
|
||||
const generateSecMsGec = () => {
|
||||
let ticks = Math.floor(Date.now() / 1000);
|
||||
// Switch to Windows file time epoch (1601-01-01 00:00:00 UTC)
|
||||
ticks += WIN_EPOCH_OFFSET;
|
||||
// Round down to the nearest 5 minutes (300 seconds)
|
||||
ticks -= ticks % 300;
|
||||
// Convert the ticks to 100-nanosecond intervals (Windows file time format)
|
||||
ticks *= S_TO_NS / 100;
|
||||
// Create the string to hash by concatenating the ticks and the trusted client token
|
||||
const strToHash = `${ticks.toFixed(0)}${EDGE_API_TOKEN}`;
|
||||
// Compute the SHA256 hash and return the uppercased hex digest
|
||||
return createHash('sha256').update(strToHash, 'ascii').digest('hex').toUpperCase();
|
||||
};
|
||||
|
||||
const genVoiceList = (voices: Record<string, string[]>) => {
|
||||
return Object.entries(voices).flatMap(([lang, voices]) => {
|
||||
return voices.map((id) => {
|
||||
@@ -150,7 +177,13 @@ export class EdgeSpeechTTS {
|
||||
|
||||
async #fetchEdgeSpeechWs({ lang, text, voice, rate }: EdgeTTSPayload): Promise<Response> {
|
||||
const connectId = randomMd5();
|
||||
const url = `${EDGE_SPEECH_URL}?ConnectionId=${connectId}&TrustedClientToken=${EDGE_API_TOKEN}`;
|
||||
const params = new URLSearchParams({
|
||||
ConnectionId: connectId,
|
||||
TrustedClientToken: EDGE_API_TOKEN,
|
||||
'Sec-MS-GEC': generateSecMsGec(),
|
||||
'Sec-MS-GEC-Version': `1-${CHROMIUM_FULL_VERSION}`,
|
||||
});
|
||||
const url = `${EDGE_SPEECH_URL}?${params.toString()}`;
|
||||
const date = new Date().toString();
|
||||
const configHeaders = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
|
||||
@@ -239,7 +239,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
response = await POST(nextReq);
|
||||
} else {
|
||||
res.setHeader('Allow', ['GET', 'POST']);
|
||||
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
return res.status(405).json({ error: 'Method Not Allowed' });
|
||||
}
|
||||
|
||||
res.status(response.status);
|
||||
|
||||
@@ -7,18 +7,17 @@ import {
|
||||
getDir,
|
||||
getLocalBookFilename,
|
||||
getRemoteBookFilename,
|
||||
getBaseFilename,
|
||||
getCoverFilename,
|
||||
getConfigFilename,
|
||||
getLibraryFilename,
|
||||
INIT_BOOK_CONFIG,
|
||||
formatTitle,
|
||||
formatAuthors,
|
||||
getFilename,
|
||||
getPrimaryLanguage,
|
||||
getLibraryBackupFilename,
|
||||
} from '@/utils/book';
|
||||
import { partialMD5 } from '@/utils/md5';
|
||||
import { getBaseFilename, getFilename } from '@/utils/path';
|
||||
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';
|
||||
import {
|
||||
DEFAULT_BOOK_LAYOUT,
|
||||
|
||||
@@ -17,8 +17,9 @@ import { stubTranslation as _ } from '@/utils/misc';
|
||||
|
||||
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
|
||||
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
|
||||
export const LOCAL_FONTS_SUBDIR = 'Readest/Fonts';
|
||||
|
||||
export const SUPPORTED_FILE_EXTS = [
|
||||
export const SUPPORTED_BOOK_EXTS = [
|
||||
'epub',
|
||||
'mobi',
|
||||
'azw',
|
||||
@@ -29,7 +30,7 @@ export const SUPPORTED_FILE_EXTS = [
|
||||
'pdf',
|
||||
'txt',
|
||||
];
|
||||
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
|
||||
export const BOOK_ACCEPT_FORMATS = SUPPORTED_BOOK_EXTS.map((ext) => `.${ext}`).join(', ');
|
||||
export const BOOK_UNGROUPED_NAME = '';
|
||||
export const BOOK_UNGROUPED_ID = '';
|
||||
|
||||
@@ -495,7 +496,6 @@ export const ANDROID_FONTS = [
|
||||
'XiHeiti',
|
||||
];
|
||||
|
||||
export const CJK_NAMES_PATTENS = /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/;
|
||||
export const CJK_EXCLUDE_PATTENS = new RegExp(
|
||||
['AlBayan', 'STIX', 'Kailasa', 'ITCTT', 'Luminari', 'Myanmar'].join('|'),
|
||||
'i',
|
||||
|
||||
@@ -28,12 +28,13 @@ import { type as osType } from '@tauri-apps/plugin-os';
|
||||
import { Book } from '@/types/book';
|
||||
import { FileSystem, BaseDir, AppPlatform } from '@/types/system';
|
||||
import { getOSPlatform, isContentURI, isValidURL } from '@/utils/misc';
|
||||
import { getCoverFilename, getFilename } from '@/utils/book';
|
||||
import { getCoverFilename } from '@/utils/book';
|
||||
import { getFilename } from '@/utils/path';
|
||||
import { copyURIToPath } from '@/utils/bridge';
|
||||
import { NativeFile, RemoteFile } from '@/utils/file';
|
||||
|
||||
import { BaseAppService, ResolvedPath } from './appService';
|
||||
import { LOCAL_BOOKS_SUBDIR } from './constants';
|
||||
import { LOCAL_BOOKS_SUBDIR, LOCAL_FONTS_SUBDIR } from './constants';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -64,6 +65,13 @@ const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
|
||||
fp: `${LOCAL_BOOKS_SUBDIR}/${path}`,
|
||||
base,
|
||||
};
|
||||
case 'Fonts':
|
||||
return {
|
||||
baseDir: BaseDirectory.AppData,
|
||||
basePrefix: appDataDir,
|
||||
fp: `${LOCAL_FONTS_SUBDIR}/${path}`,
|
||||
base,
|
||||
};
|
||||
case 'None':
|
||||
return {
|
||||
baseDir: 0,
|
||||
@@ -71,6 +79,7 @@ const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
|
||||
fp: path,
|
||||
base,
|
||||
};
|
||||
case 'Temp':
|
||||
default:
|
||||
return {
|
||||
baseDir: BaseDirectory.Temp,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { md5 } from 'js-md5';
|
||||
import { Book } from '@/types/book';
|
||||
import { KoreaderSyncChecksumMethod } from '@/types/settings';
|
||||
import { getAPIBaseUrl } from '../environment';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Interface for KOSync progress response from the server
|
||||
@@ -23,6 +26,7 @@ export class KOSyncClient {
|
||||
private checksumMethod: KoreaderSyncChecksumMethod;
|
||||
private deviceId: string;
|
||||
private deviceName: string;
|
||||
private isLanServer: boolean;
|
||||
|
||||
constructor(
|
||||
serverUrl: string,
|
||||
@@ -38,6 +42,7 @@ export class KOSyncClient {
|
||||
this.checksumMethod = checksumMethod;
|
||||
this.deviceId = deviceId;
|
||||
this.deviceName = deviceName;
|
||||
this.isLanServer = isLanAddress(this.serverUrl);
|
||||
}
|
||||
|
||||
private async request(
|
||||
@@ -57,6 +62,24 @@ export class KOSyncClient {
|
||||
headers.set('X-Auth-Key', this.userkey);
|
||||
}
|
||||
|
||||
if (this.isLanServer) {
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
const directUrl = `${this.serverUrl}${endpoint}`;
|
||||
|
||||
return fetch(directUrl, {
|
||||
method,
|
||||
headers: {
|
||||
accept: 'application/vnd.koreader.v1+json',
|
||||
...Object.fromEntries(headers.entries()),
|
||||
},
|
||||
body,
|
||||
danger: {
|
||||
acceptInvalidCerts: true,
|
||||
acceptInvalidHostnames: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const proxyUrl = `${getAPIBaseUrl()}/kosync`;
|
||||
|
||||
const proxyBody: KoSyncProxyPayload = {
|
||||
@@ -201,18 +224,9 @@ export class KOSyncClient {
|
||||
|
||||
private getDocumentDigest(book: Book): string | undefined {
|
||||
if (this.checksumMethod === 'filename') {
|
||||
const filename = this.getBaseFilename(book.sourceTitle || book.title);
|
||||
const filename = getBaseFilename(book.sourceTitle || book.title);
|
||||
return md5(filename);
|
||||
}
|
||||
return book.hash;
|
||||
}
|
||||
|
||||
private getBaseFilename(fullPath: string): string {
|
||||
// Normalize path by replacing backslashes with forward slashes for cross-platform consistency
|
||||
const normalizedPath = fullPath.replace(/\\/g, '/');
|
||||
// Get the last part of the path and remove the extension
|
||||
const baseName =
|
||||
normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath;
|
||||
return baseName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,18 +97,17 @@ export class EdgeTTSClient implements TTSClient {
|
||||
} as TTSMessageEvent;
|
||||
|
||||
return;
|
||||
} else {
|
||||
await this.stopInternal();
|
||||
}
|
||||
|
||||
await this.stopInternal();
|
||||
// Reuse the same Audio element inside the ssml session
|
||||
this.#audioElement = new Audio();
|
||||
const audio = this.#audioElement;
|
||||
audio.setAttribute('x-webkit-airplay', 'deny');
|
||||
audio.preload = 'auto';
|
||||
|
||||
for (const mark of marks) {
|
||||
if (signal.aborted) {
|
||||
yield {
|
||||
code: 'error',
|
||||
message: 'Aborted',
|
||||
} as TTSMessageEvent;
|
||||
break;
|
||||
}
|
||||
let abortHandler: null | (() => void) = null;
|
||||
try {
|
||||
const { language: voiceLang } = mark;
|
||||
const voiceId = await this.getVoiceIdFromLang(voiceLang);
|
||||
@@ -116,11 +115,7 @@ export class EdgeTTSClient implements TTSClient {
|
||||
const blob = await this.#edgeTTS.createAudio(
|
||||
this.getPayload(voiceLang, mark.text, voiceId),
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.#audioElement = new Audio(url);
|
||||
const audio = this.#audioElement;
|
||||
audio.setAttribute('x-webkit-airplay', 'deny');
|
||||
audio.preload = 'auto';
|
||||
audio.src = URL.createObjectURL(blob);
|
||||
|
||||
this.controller?.dispatchSpeakMark(mark);
|
||||
|
||||
@@ -135,27 +130,30 @@ export class EdgeTTSClient implements TTSClient {
|
||||
audio.onended = null;
|
||||
audio.onerror = null;
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
URL.revokeObjectURL(url);
|
||||
if (audio.src) {
|
||||
URL.revokeObjectURL(audio.src);
|
||||
audio.removeAttribute('src');
|
||||
}
|
||||
};
|
||||
abortHandler = () => {
|
||||
cleanUp();
|
||||
resolve({ code: 'error', message: 'Aborted' });
|
||||
};
|
||||
if (signal.aborted) {
|
||||
abortHandler();
|
||||
return;
|
||||
} else {
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
}
|
||||
audio.onended = () => {
|
||||
cleanUp();
|
||||
if (signal.aborted) {
|
||||
resolve({ code: 'error', message: 'Aborted' });
|
||||
} else {
|
||||
resolve({ code: 'end', message: `Chunk finished: ${mark.name}` });
|
||||
}
|
||||
resolve({ code: 'end', message: `Chunk finished: ${mark.name}` });
|
||||
};
|
||||
audio.onerror = (e) => {
|
||||
cleanUp();
|
||||
console.warn('Audio playback error:', e);
|
||||
resolve({ code: 'error', message: 'Audio playback error' });
|
||||
};
|
||||
if (signal.aborted) {
|
||||
cleanUp();
|
||||
resolve({ code: 'error', message: 'Aborted' });
|
||||
return;
|
||||
}
|
||||
this.#isPlaying = true;
|
||||
audio.play().catch((err) => {
|
||||
cleanUp();
|
||||
@@ -167,22 +165,20 @@ export class EdgeTTSClient implements TTSClient {
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'No audio data received.') {
|
||||
console.warn('No audio data received for:', mark.text);
|
||||
yield {
|
||||
code: 'end',
|
||||
message: `Chunk finished: ${mark.name}`,
|
||||
} as TTSMessageEvent;
|
||||
yield { code: 'end', message: `Chunk finished: ${mark.name}` } as TTSMessageEvent;
|
||||
continue;
|
||||
}
|
||||
console.log('Error:', error);
|
||||
yield {
|
||||
code: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} as TTSMessageEvent;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn('TTS error for mark:', mark.text, message);
|
||||
yield { code: 'error', message } as TTSMessageEvent;
|
||||
break;
|
||||
} finally {
|
||||
if (abortHandler) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
}
|
||||
|
||||
await this.stopInternal();
|
||||
}
|
||||
await this.stopInternal();
|
||||
}
|
||||
|
||||
async pause() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getOSPlatform, isValidURL } from '@/utils/misc';
|
||||
import { RemoteFile } from '@/utils/file';
|
||||
import { isPWA } from './environment';
|
||||
import { BaseAppService, ResolvedPath } from './appService';
|
||||
import { LOCAL_BOOKS_SUBDIR } from './constants';
|
||||
import { LOCAL_BOOKS_SUBDIR, LOCAL_FONTS_SUBDIR } from './constants';
|
||||
|
||||
const basePrefix = async () => '';
|
||||
|
||||
@@ -13,6 +13,8 @@ const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
|
||||
switch (base) {
|
||||
case 'Books':
|
||||
return { baseDir: 0, basePrefix, fp: `${LOCAL_BOOKS_SUBDIR}/${path}`, base };
|
||||
case 'Fonts':
|
||||
return { baseDir: 0, basePrefix, fp: `${LOCAL_FONTS_SUBDIR}/${path}`, base };
|
||||
case 'None':
|
||||
return { baseDir: 0, basePrefix, fp: path, base };
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { CustomFont, createCustomFont, getFontFormat, getMimeType } from '@/styles/fonts';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
|
||||
interface FontStoreState {
|
||||
fonts: CustomFont[];
|
||||
loading: boolean;
|
||||
|
||||
setFonts: (fonts: CustomFont[]) => void;
|
||||
addFont: (path: string, options?: { name?: string }) => CustomFont;
|
||||
removeFont: (id: string) => boolean;
|
||||
updateFont: (id: string, updates: Partial<CustomFont>) => boolean;
|
||||
getFont: (id: string) => CustomFont | undefined;
|
||||
getAllFonts: () => CustomFont[];
|
||||
getAvailableFonts: () => CustomFont[];
|
||||
clearAllFonts: () => void;
|
||||
|
||||
loadFont: (envConfig: EnvConfigType, fontId: string) => Promise<CustomFont>;
|
||||
loadFonts: (envConfig: EnvConfigType, fontIds: string[]) => Promise<CustomFont[]>;
|
||||
loadAllFonts: (envConfig: EnvConfigType) => Promise<CustomFont[]>;
|
||||
unloadFont: (fontId: string) => boolean;
|
||||
unloadAllFonts: () => void;
|
||||
|
||||
getFontFamilies: () => string[];
|
||||
getLoadedFonts: () => CustomFont[];
|
||||
isFontLoaded: (fontId: string) => boolean;
|
||||
|
||||
loadCustomFonts: (envConfig: EnvConfigType) => Promise<void>;
|
||||
saveCustomFonts: (envConfig: EnvConfigType) => Promise<void>;
|
||||
}
|
||||
|
||||
function toSettingsFont(font: CustomFont): CustomFont {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { blobUrl, loaded, error, ...settingsFont } = font;
|
||||
return settingsFont;
|
||||
}
|
||||
|
||||
export const useCustomFontStore = create<FontStoreState>((set, get) => ({
|
||||
fonts: [],
|
||||
loading: false,
|
||||
|
||||
setFonts: (fonts) => set({ fonts }),
|
||||
addFont: (path, options) => {
|
||||
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;
|
||||
}
|
||||
return existingFont;
|
||||
}
|
||||
|
||||
const newFont = {
|
||||
...font,
|
||||
downloadedAt: Date.now(),
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
fonts: [...state.fonts, newFont],
|
||||
}));
|
||||
|
||||
return newFont;
|
||||
},
|
||||
|
||||
removeFont: (id) => {
|
||||
const font = get().getFont(id);
|
||||
if (!font) return false;
|
||||
|
||||
if (font.blobUrl) {
|
||||
URL.revokeObjectURL(font.blobUrl);
|
||||
}
|
||||
|
||||
const result = get().updateFont(id, {
|
||||
deletedAt: Date.now(),
|
||||
blobUrl: undefined,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
});
|
||||
set((state) => ({
|
||||
fonts: [...state.fonts],
|
||||
}));
|
||||
return result;
|
||||
},
|
||||
|
||||
updateFont: (id, updates) => {
|
||||
const state = get();
|
||||
const fontIndex = state.fonts.findIndex((font) => font.id === id);
|
||||
|
||||
if (fontIndex === -1) return false;
|
||||
|
||||
set((state) => ({
|
||||
fonts: state.fonts.map((font, index) =>
|
||||
index === fontIndex ? { ...font, ...updates } : font,
|
||||
),
|
||||
}));
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
getFont: (id) => {
|
||||
return get().fonts.find((font) => font.id === id);
|
||||
},
|
||||
|
||||
getAllFonts: () => {
|
||||
return get().fonts;
|
||||
},
|
||||
|
||||
getAvailableFonts: () => {
|
||||
return get().fonts.filter((font) => !font.deletedAt);
|
||||
},
|
||||
|
||||
clearAllFonts: () => {
|
||||
const { fonts } = get();
|
||||
fonts.forEach((font) => {
|
||||
if (font.blobUrl) {
|
||||
URL.revokeObjectURL(font.blobUrl);
|
||||
}
|
||||
});
|
||||
|
||||
set({ fonts: [] });
|
||||
},
|
||||
|
||||
loadFont: async (envConfig, fontId) => {
|
||||
const font = get().getFont(fontId);
|
||||
|
||||
if (!font) {
|
||||
throw new Error(`Font with id "${fontId}" not found`);
|
||||
}
|
||||
|
||||
if (font.deletedAt) {
|
||||
throw new Error(`Font "${font.name}" has been deleted`);
|
||||
}
|
||||
|
||||
if (font.loaded && font.blobUrl && !font.error) {
|
||||
return font;
|
||||
}
|
||||
|
||||
try {
|
||||
get().updateFont(fontId, {
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const appService = await envConfig.getAppService();
|
||||
const fontFile = await appService.fs.openFile(font.path, 'Fonts');
|
||||
|
||||
const format = getFontFormat(font.path);
|
||||
const mimeType = getMimeType(format);
|
||||
const blob = new Blob([await fontFile.arrayBuffer()], { type: mimeType });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
get().updateFont(fontId, {
|
||||
blobUrl,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const updatedFont = get().getFont(fontId)!;
|
||||
return updatedFont;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
get().updateFont(fontId, {
|
||||
loaded: false,
|
||||
error: errorMessage,
|
||||
blobUrl: undefined,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadFonts: async (envConfig, fontIds) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const results = await Promise.allSettled(fontIds.map((id) => get().loadFont(envConfig, id)));
|
||||
|
||||
return results
|
||||
.filter(
|
||||
(result): result is PromiseFulfilledResult<CustomFont> => result.status === 'fulfilled',
|
||||
)
|
||||
.map((result) => result.value);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadAllFonts: async (envConfig) => {
|
||||
const fontIds = get()
|
||||
.getAvailableFonts()
|
||||
.map((font) => font.id);
|
||||
return await get().loadFonts(envConfig, fontIds);
|
||||
},
|
||||
|
||||
unloadFont: (fontId) => {
|
||||
const font = get().getFont(fontId);
|
||||
|
||||
if (font?.blobUrl) {
|
||||
URL.revokeObjectURL(font.blobUrl);
|
||||
}
|
||||
|
||||
return get().updateFont(fontId, {
|
||||
blobUrl: undefined,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
unloadAllFonts: () => {
|
||||
const fonts = get().getAllFonts();
|
||||
|
||||
fonts.forEach((font) => {
|
||||
if (font.blobUrl) {
|
||||
URL.revokeObjectURL(font.blobUrl);
|
||||
}
|
||||
});
|
||||
|
||||
fonts.forEach((font) => {
|
||||
get().updateFont(font.id, {
|
||||
blobUrl: undefined,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getFontFamilies: () => {
|
||||
return get()
|
||||
.getAvailableFonts()
|
||||
.filter((font) => font.loaded && !font.error)
|
||||
.map((font) => font.name);
|
||||
},
|
||||
|
||||
getLoadedFonts: () => {
|
||||
return get()
|
||||
.getAvailableFonts()
|
||||
.filter((font) => font.loaded && !font.error);
|
||||
},
|
||||
|
||||
isFontLoaded: (fontId) => {
|
||||
const font = get().getFont(fontId);
|
||||
return font?.loaded === true && !font.error && !font.deletedAt;
|
||||
},
|
||||
|
||||
loadCustomFonts: async (envConfig) => {
|
||||
try {
|
||||
const { settings } = useSettingsStore.getState();
|
||||
const currentFonts = get().fonts;
|
||||
if (settings?.customFonts) {
|
||||
const fonts = settings.customFonts.map((font) => {
|
||||
const existingFont = currentFonts.find((f) => f.id === font.id);
|
||||
return {
|
||||
...font,
|
||||
loaded: existingFont?.loaded || false,
|
||||
error: existingFont?.error,
|
||||
blobUrl: existingFont?.blobUrl,
|
||||
};
|
||||
});
|
||||
set({ fonts });
|
||||
await get().loadAllFonts(envConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom fonts settings:', error);
|
||||
}
|
||||
},
|
||||
|
||||
saveCustomFonts: async (envConfig) => {
|
||||
try {
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore.getState();
|
||||
const { fonts } = get();
|
||||
settings.customFonts = fonts.map(toSettingsFont);
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to save custom fonts settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
const store = useCustomFontStore.getState();
|
||||
const fonts = store.getAllFonts();
|
||||
fonts.forEach((font) => {
|
||||
if (font.blobUrl) {
|
||||
URL.revokeObjectURL(font.blobUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import { EnvConfigType } from '@/services/environment';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { DocumentLoader, TOCItem } from '@/libs/document';
|
||||
import { updateToc } from '@/utils/toc';
|
||||
import { formatTitle, getBaseFilename, getPrimaryLanguage } from '@/utils/book';
|
||||
import { formatTitle, getPrimaryLanguage } from '@/utils/book';
|
||||
import { getBaseFilename } from '@/utils/path';
|
||||
import { SUPPORTED_LANGNAMES } from '@/services/constants';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { useBookDataStore } from './bookDataStore';
|
||||
|
||||
@@ -2,20 +2,25 @@ import { create } from 'zustand';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
|
||||
export type FontPanelView = 'main-fonts' | 'custom-fonts';
|
||||
|
||||
interface SettingsState {
|
||||
settings: SystemSettings;
|
||||
isFontLayoutSettingsDialogOpen: boolean;
|
||||
isFontLayoutSettingsGlobal: boolean;
|
||||
fontPanelView: FontPanelView;
|
||||
setSettings: (settings: SystemSettings) => void;
|
||||
saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void;
|
||||
setFontLayoutSettingsDialogOpen: (open: boolean) => void;
|
||||
setFontLayoutSettingsGlobal: (global: boolean) => void;
|
||||
setFontPanelView: (view: FontPanelView) => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
settings: {} as SystemSettings,
|
||||
isFontLayoutSettingsDialogOpen: false,
|
||||
isFontLayoutSettingsGlobal: true,
|
||||
fontPanelView: 'main-fonts',
|
||||
setSettings: (settings) => set({ settings }),
|
||||
saveSettings: async (envConfig: EnvConfigType, settings: SystemSettings) => {
|
||||
const appService = await envConfig.getAppService();
|
||||
@@ -23,4 +28,5 @@ export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
},
|
||||
setFontLayoutSettingsDialogOpen: (open) => set({ isFontLayoutSettingsDialogOpen: open }),
|
||||
setFontLayoutSettingsGlobal: (global) => set({ isFontLayoutSettingsGlobal: global }),
|
||||
setFontPanelView: (view) => set({ fontPanelView: view }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { isCJKEnv } from '@/utils/misc';
|
||||
import { getFilename } from '@/utils/path';
|
||||
import { md5Fingerprint } from '@/utils/md5';
|
||||
|
||||
export type FontFormat = 'ttf' | 'otf' | 'woff' | 'woff2';
|
||||
|
||||
const basicGoogleFonts = [
|
||||
{ family: 'Bitter', weights: 'ital,wght@0,100..900;1,100..900' },
|
||||
{ family: 'Fira Code', weights: 'wght@300..700' },
|
||||
{ family: 'Literata', weights: 'ital,opsz,wght@0,7..72,200..900;1,7..72,200..900' },
|
||||
{ family: 'Merriweather', weights: 'ital,opsz,wght@0,18..144,300..900;1,18..144,300..900' },
|
||||
{ family: 'Noto Sans', weights: 'ital,wght@0,100..900;1,100..900' },
|
||||
{ family: 'Open Sans', weights: 'ital,wght@0,300..800;1,300..800' },
|
||||
{ family: 'Roboto', weights: 'ital,wght@0,100..900;1,100..900' },
|
||||
{ family: 'Vollkorn', weights: 'ital,wght@0,400..900;1,400..900' },
|
||||
];
|
||||
|
||||
const cjkGoogleFonts = [
|
||||
{ family: 'LXGW WenKai TC', weights: '' },
|
||||
{ family: 'Noto Sans SC', weights: '' },
|
||||
{ family: 'Noto Sans TC', weights: '' },
|
||||
{ family: 'Noto Serif JP', weights: '' },
|
||||
];
|
||||
|
||||
const getAdditionalBasicFontLinks = () => `
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${basicGoogleFonts
|
||||
.map(
|
||||
({ family, weights }) =>
|
||||
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
|
||||
)
|
||||
.join('&')}&display=swap" crossorigin="anonymous">
|
||||
`;
|
||||
|
||||
const getAdditionalCJKFontLinks = () => `
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/hwmct/dist/%E6%B1%87%E6%96%87%E6%98%8E%E6%9C%9D%E4%BD%93/result.css' crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/jhlst/dist/%E4%BA%AC%E8%8F%AF%E8%80%81%E5%AE%8B%E4%BD%93v2_002/result.css' crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/syst/dist/SourceHanSerifCN/result.css' crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/GuanKiapTsingKhai/dist/GuanKiapTsingKhai-T/result.css' crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${cjkGoogleFonts
|
||||
.map(
|
||||
({ family, weights }) =>
|
||||
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
|
||||
)
|
||||
.join('&')}&display=swap" crossorigin="anonymous" />
|
||||
`;
|
||||
|
||||
const getAdditionalCJKFontFaces = () => `
|
||||
@font-face {
|
||||
font-family: "FangSong";
|
||||
font-display: swap;
|
||||
src: local("Fang Song"), local("FangSong"), local("Noto Serif CJK"), local("Source Han Serif SC VF"), url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.ttf") format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.svg#FangSong") format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Kaiti";
|
||||
font-display: swap;
|
||||
src: local("Kai"), local("KaiTi"), local("AR PL UKai"), local("LXGW WenKai GB Screen"), url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.svg#STKaiti")format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Heiti";
|
||||
font-display: swap;
|
||||
src: local("Hei"), local("SimHei"), local("WenQuanYi Zen Hei"), local("Source Han Sans SC VF"), url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.svg#WenQuanYi Micro Hei")format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "XiHeiti";
|
||||
font-display: swap;
|
||||
src: local("PingFang SC"), local("Microsoft YaHei"), local("WenQuanYi Micro Hei"), local("FZHei-B01"), url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.svg#STHeiti J Light")format("svg");
|
||||
}
|
||||
`;
|
||||
|
||||
export const mountAdditionalFonts = (document: Document, isCJK = false) => {
|
||||
const mountCJKFonts = isCJK || isCJKEnv();
|
||||
let links = getAdditionalBasicFontLinks();
|
||||
if (mountCJKFonts) {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = getAdditionalCJKFontFaces();
|
||||
document.head.appendChild(style);
|
||||
|
||||
links = `${links}\n${getAdditionalCJKFontLinks()}`;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const parsedDocument = parser.parseFromString(links, 'text/html');
|
||||
|
||||
Array.from(parsedDocument.head.children).forEach((child) => {
|
||||
if (child.tagName === 'LINK') {
|
||||
const link = document.createElement('link');
|
||||
link.rel = child.getAttribute('rel') || '';
|
||||
link.href = child.getAttribute('href') || '';
|
||||
link.crossOrigin = child.getAttribute('crossorigin') || '';
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export interface CustomFont {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
downloadedAt?: number;
|
||||
deletedAt?: number;
|
||||
|
||||
blobUrl?: string;
|
||||
loaded?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function getFontName(path: string): string {
|
||||
const fileName = getFilename(path);
|
||||
return fileName.replace(/\.(ttf|otf|woff|woff2)$/i, '');
|
||||
}
|
||||
|
||||
export function getFontId(name: string): string {
|
||||
return md5Fingerprint(name);
|
||||
}
|
||||
|
||||
export function getFontFormat(path: string): FontFormat {
|
||||
const extension = path.toLowerCase().split('.').pop();
|
||||
switch (extension) {
|
||||
case 'ttf':
|
||||
return 'ttf';
|
||||
case 'otf':
|
||||
return 'otf';
|
||||
case 'woff':
|
||||
return 'woff';
|
||||
case 'woff2':
|
||||
return 'woff2';
|
||||
default:
|
||||
return 'ttf';
|
||||
}
|
||||
}
|
||||
|
||||
export function getMimeType(format: FontFormat): string {
|
||||
const types = { ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2' };
|
||||
return types[format] || 'font/ttf';
|
||||
}
|
||||
|
||||
export function getCSSFormatString(format: FontFormat): string {
|
||||
const formats = { ttf: 'truetype', otf: 'opentype', woff: 'woff', woff2: 'woff2' };
|
||||
return formats[format] || 'truetype';
|
||||
}
|
||||
|
||||
export function createFontFamily(name: string): string {
|
||||
return name.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function createFontCSS(font: CustomFont): string {
|
||||
const format = getFontFormat(font.path);
|
||||
const cssFormat = getCSSFormatString(format);
|
||||
const fontFamily = createFontFamily(font.name);
|
||||
if (!font.blobUrl) {
|
||||
throw new Error(`Blob URL not available for font: ${font.name}`);
|
||||
}
|
||||
|
||||
const css = `
|
||||
@font-face {
|
||||
font-family: "${fontFamily}";
|
||||
src: url("${font.blobUrl}") format("${cssFormat}");
|
||||
font-display: swap;
|
||||
}
|
||||
`;
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
export function createCustomFont(
|
||||
path: string,
|
||||
options?: {
|
||||
name?: string;
|
||||
},
|
||||
): CustomFont {
|
||||
const name = options?.name || getFontName(path);
|
||||
return { id: getFontId(name), name, path };
|
||||
}
|
||||
|
||||
export const mountCustomFont = (document: Document, font: CustomFont) => {
|
||||
const fontStyleId = `custom-font-${font.id}`;
|
||||
const styleElement = document.getElementById(fontStyleId) || document.createElement('style');
|
||||
styleElement.id = fontStyleId;
|
||||
styleElement.textContent = createFontCSS(font);
|
||||
|
||||
if (!styleElement.parentNode) {
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CustomTheme } from '@/styles/themes';
|
||||
import { CustomFont } from '@/styles/fonts';
|
||||
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
|
||||
|
||||
export type ThemeType = 'light' | 'dark' | 'auto';
|
||||
@@ -42,6 +43,7 @@ export interface SystemSettings {
|
||||
librarySortBy: LibrarySortByType;
|
||||
librarySortAscending: boolean;
|
||||
libraryCoverFit: LibraryCoverFitType;
|
||||
customFonts: CustomFont[];
|
||||
|
||||
koreaderSyncServerUrl: string;
|
||||
koreaderSyncUsername: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ProgressHandler } from '@/utils/transfer';
|
||||
|
||||
export type AppPlatform = 'web' | 'tauri';
|
||||
export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
|
||||
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None';
|
||||
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Fonts' | 'Log' | 'Cache' | 'Temp' | 'None';
|
||||
export type DeleteAction = 'cloud' | 'local' | 'both';
|
||||
|
||||
export interface FileSystem {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EXTS } from '@/libs/document';
|
||||
import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
|
||||
import { SUPPORTED_LANGS } from '@/services/constants';
|
||||
import { getUserLang, isContentURI, isFileURI, isValidURL, makeSafeFilename } from './misc';
|
||||
import { getUserLang, makeSafeFilename } from './misc';
|
||||
import { getStorageType } from './storage';
|
||||
import { getDirFromLanguage } from './rtl';
|
||||
import { code6392to6391, isValidLang, normalizedLangCode } from './lang';
|
||||
@@ -37,20 +37,6 @@ export const getConfigFilename = (book: Book) => {
|
||||
export const isBookFile = (filename: string) => {
|
||||
return Object.values(EXTS).includes(filename.split('.').pop()!);
|
||||
};
|
||||
export const getFilename = (fileOrUri: string) => {
|
||||
if (isValidURL(fileOrUri) || isContentURI(fileOrUri) || isFileURI(fileOrUri)) {
|
||||
fileOrUri = decodeURI(fileOrUri);
|
||||
}
|
||||
const normalizedPath = fileOrUri.replace(/\\/g, '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
const lastPart = parts.pop()!;
|
||||
return lastPart.split('?')[0]!;
|
||||
};
|
||||
export const getBaseFilename = (filename: string) => {
|
||||
const normalizedPath = filename.replace(/\\/g, '/');
|
||||
const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
||||
return baseName;
|
||||
};
|
||||
|
||||
export const INIT_BOOK_CONFIG: BookConfig = {
|
||||
updatedAt: 0,
|
||||
@@ -105,21 +91,38 @@ export const flattenContributors = (
|
||||
: formatLanguageMap(contributors?.name);
|
||||
};
|
||||
|
||||
// prettier-ignore
|
||||
const LASTNAME_AUTHOR_SORT_LANGS = [ 'ar', 'bo', 'de', 'en', 'es', 'fr', 'hi', 'it', 'nl', 'pl', 'pt', 'ru', 'th', 'tr', 'uk' ];
|
||||
|
||||
const formatAuthorName = (name: string, lastNameFirst: boolean) => {
|
||||
if (!name) return '';
|
||||
const parts = name.split(' ');
|
||||
if (lastNameFirst && parts.length > 1) {
|
||||
return `${parts[parts.length - 1]}, ${parts.slice(0, -1).join(' ')}`;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
export const formatAuthors = (
|
||||
contributors: string | string[] | Contributor | Contributor[],
|
||||
bookLang?: string | string[],
|
||||
sortAs?: boolean,
|
||||
) => {
|
||||
const langCode = getBookLangCode(bookLang) || 'en';
|
||||
const lastNameFirst = !!sortAs && LASTNAME_AUTHOR_SORT_LANGS.includes(langCode);
|
||||
return Array.isArray(contributors)
|
||||
? listFormater(langCode === 'zh', langCode).format(
|
||||
contributors.map((contributor) =>
|
||||
typeof contributor === 'string' ? contributor : formatLanguageMap(contributor?.name),
|
||||
typeof contributor === 'string'
|
||||
? formatAuthorName(contributor, lastNameFirst)
|
||||
: formatAuthorName(formatLanguageMap(contributor?.name), lastNameFirst),
|
||||
),
|
||||
)
|
||||
: typeof contributors === 'string'
|
||||
? contributors
|
||||
: formatLanguageMap(contributors?.name);
|
||||
? formatAuthorName(contributors, lastNameFirst)
|
||||
: formatAuthorName(formatLanguageMap(contributors?.name), lastNameFirst);
|
||||
};
|
||||
|
||||
export const formatTitle = (title: string | LanguageMap) => {
|
||||
return typeof title === 'string' ? title : formatLanguageMap(title);
|
||||
};
|
||||
|
||||
+154
-109
@@ -1,112 +1,157 @@
|
||||
import { isCJKEnv } from './misc';
|
||||
import { getUserLang } from './misc';
|
||||
|
||||
const basicGoogleFonts = [
|
||||
{ family: 'Bitter', weights: 'ital,wght@0,100..900;1,100..900' },
|
||||
{ family: 'Fira Code', weights: 'wght@300..700' },
|
||||
{ family: 'Literata', weights: 'ital,opsz,wght@0,7..72,200..900;1,7..72,200..900' },
|
||||
{ family: 'Merriweather', weights: 'ital,opsz,wght@0,18..144,300..900;1,18..144,300..900' },
|
||||
{ family: 'Noto Sans', weights: 'ital,wght@0,100..900;1,100..900' },
|
||||
{ family: 'Open Sans', weights: 'ital,wght@0,300..800;1,300..800' },
|
||||
{ family: 'Roboto', weights: 'ital,wght@0,100..900;1,100..900' },
|
||||
{ family: 'Vollkorn', weights: 'ital,wght@0,400..900;1,400..900' },
|
||||
];
|
||||
|
||||
const cjkGoogleFonts = [
|
||||
{ family: 'LXGW WenKai TC', weights: '' },
|
||||
{ family: 'Noto Sans SC', weights: '' },
|
||||
{ family: 'Noto Sans TC', weights: '' },
|
||||
{ family: 'Noto Serif JP', weights: '' },
|
||||
];
|
||||
|
||||
const getAdditionalBasicFontLinks = () => `
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${basicGoogleFonts
|
||||
.map(
|
||||
({ family, weights }) =>
|
||||
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
|
||||
)
|
||||
.join('&')}&display=swap" crossorigin="anonymous">
|
||||
`;
|
||||
|
||||
const getAdditionalCJKFontLinks = () => `
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/hwmct/dist/%E6%B1%87%E6%96%87%E6%98%8E%E6%9C%9D%E4%BD%93/result.css' crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/jhlst/dist/%E4%BA%AC%E8%8F%AF%E8%80%81%E5%AE%8B%E4%BD%93v2_002/result.css' crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/syst/dist/SourceHanSerifCN/result.css' crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/GuanKiapTsingKhai/dist/GuanKiapTsingKhai-T/result.css' crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${cjkGoogleFonts
|
||||
.map(
|
||||
({ family, weights }) =>
|
||||
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
|
||||
)
|
||||
.join('&')}&display=swap" crossorigin="anonymous" />
|
||||
`;
|
||||
|
||||
const getAdditionalCJKFontFaces = () => `
|
||||
@font-face {
|
||||
font-family: "FangSong";
|
||||
font-display: swap;
|
||||
src: local("Fang Song"), local("FangSong"), local("Noto Serif CJK"), local("Source Han Serif SC VF"), url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.ttf") format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.svg#FangSong") format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Kaiti";
|
||||
font-display: swap;
|
||||
src: local("Kai"), local("KaiTi"), local("AR PL UKai"), local("LXGW WenKai GB Screen"), url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.svg#STKaiti")format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Heiti";
|
||||
font-display: swap;
|
||||
src: local("Hei"), local("SimHei"), local("WenQuanYi Zen Hei"), local("Source Han Sans SC VF"), url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.svg#WenQuanYi Micro Hei")format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "XiHeiti";
|
||||
font-display: swap;
|
||||
src: local("PingFang SC"), local("Microsoft YaHei"), local("WenQuanYi Micro Hei"), local("FZHei-B01"), url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.svg#STHeiti J Light")format("svg");
|
||||
}
|
||||
`;
|
||||
|
||||
export const mountAdditionalFonts = (document: Document, isCJK = false) => {
|
||||
const mountCJKFonts = isCJK || isCJKEnv();
|
||||
let links = getAdditionalBasicFontLinks();
|
||||
if (mountCJKFonts) {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = getAdditionalCJKFontFaces();
|
||||
document.head.appendChild(style);
|
||||
|
||||
links = `${links}\n${getAdditionalCJKFontLinks()}`;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const parsedDocument = parser.parseFromString(links, 'text/html');
|
||||
|
||||
Array.from(parsedDocument.head.children).forEach((child) => {
|
||||
if (child.tagName === 'LINK') {
|
||||
const link = document.createElement('link');
|
||||
link.rel = child.getAttribute('rel') || '';
|
||||
link.href = child.getAttribute('href') || '';
|
||||
link.crossOrigin = child.getAttribute('crossorigin') || '';
|
||||
|
||||
document.head.appendChild(link);
|
||||
function parseUnicodeString(dataView: DataView, offset: number, length: number): string {
|
||||
const chars: string[] = [];
|
||||
for (let i = 0; i < length; i += 2) {
|
||||
const charCode = dataView.getUint16(offset + i, false);
|
||||
if (charCode !== 0) {
|
||||
chars.push(String.fromCharCode(charCode));
|
||||
}
|
||||
});
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function parseMacintoshString(dataView: DataView, offset: number, length: number): string {
|
||||
const chars: string[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
const charCode = dataView.getUint8(offset + i);
|
||||
chars.push(String.fromCharCode(charCode));
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function getLanguagePriority(platformID: number, languageID: number, userLanguage: string): number {
|
||||
let priority = 0;
|
||||
|
||||
// Base priority by platform (Unicode/Microsoft preferred)
|
||||
if (platformID === 0)
|
||||
priority += 100; // Unicode
|
||||
else if (platformID === 3)
|
||||
priority += 90; // Microsoft
|
||||
else if (platformID === 1) priority += 50; // Macintosh
|
||||
|
||||
// Language-specific priorities
|
||||
const userLang = userLanguage.toLowerCase();
|
||||
|
||||
if (platformID === 0 || platformID === 3) {
|
||||
if (userLang.startsWith('zh')) {
|
||||
if (languageID === 0x0804)
|
||||
priority += 50; // Simplified Chinese
|
||||
else if (languageID === 0x0404)
|
||||
priority += 45; // Traditional Chinese
|
||||
else if (languageID === 0x0c04)
|
||||
priority += 40; // Traditional Chinese
|
||||
else if (languageID === 0x1004) priority += 35; // Simplified Chinese
|
||||
} else if (userLang.startsWith('ja')) {
|
||||
if (languageID === 0x0411) priority += 50; // Japanese
|
||||
} else if (userLang.startsWith('ko')) {
|
||||
if (languageID === 0x0412) priority += 50; // Korean
|
||||
} else if (userLang.startsWith('en')) {
|
||||
if (languageID === 0x0409)
|
||||
priority += 50; // English (US)
|
||||
else if (languageID === 0x0809) priority += 45; // English (UK)
|
||||
}
|
||||
|
||||
// Fallback: English
|
||||
if (languageID === 0x0409) priority += 10; // English fallback
|
||||
} else if (platformID === 1) {
|
||||
// Macintosh platform language codes
|
||||
if (userLang.startsWith('zh')) {
|
||||
if (languageID === 33)
|
||||
priority += 50; // Chinese (Simplified)
|
||||
else if (languageID === 19) priority += 45; // Chinese (Traditional)
|
||||
} else if (userLang.startsWith('ja')) {
|
||||
if (languageID === 11) priority += 50; // Japanese
|
||||
} else if (userLang.startsWith('ko')) {
|
||||
if (languageID === 23) priority += 50; // Korean
|
||||
} else if (userLang.startsWith('en')) {
|
||||
if (languageID === 0) priority += 50; // English
|
||||
}
|
||||
|
||||
// Fallback: English
|
||||
if (languageID === 0) priority += 10; // English fallback
|
||||
}
|
||||
|
||||
return priority;
|
||||
}
|
||||
|
||||
export const parseFontFamily = (fontData: ArrayBuffer, filename: string) => {
|
||||
const fallbackName = filename.replace(/\.[^/.]+$/, '');
|
||||
try {
|
||||
const dataView = new DataView(fontData);
|
||||
const signature = dataView.getUint32(0, false);
|
||||
if (signature !== 0x00010000 && signature !== 0x74727565 && signature !== 0x4f54544f) {
|
||||
throw new Error('Unsupported font format');
|
||||
}
|
||||
const numTables = dataView.getUint16(4, false);
|
||||
let nameTableOffset = 0;
|
||||
for (let i = 0; i < numTables; i++) {
|
||||
const tableOffset = 12 + i * 16;
|
||||
const tag = String.fromCharCode(
|
||||
dataView.getUint8(tableOffset),
|
||||
dataView.getUint8(tableOffset + 1),
|
||||
dataView.getUint8(tableOffset + 2),
|
||||
dataView.getUint8(tableOffset + 3),
|
||||
);
|
||||
|
||||
if (tag === 'name') {
|
||||
nameTableOffset = dataView.getUint32(tableOffset + 8, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nameTableOffset === 0) {
|
||||
throw new Error('Name table not found');
|
||||
}
|
||||
|
||||
const count = dataView.getUint16(nameTableOffset + 2, false);
|
||||
const stringOffset = dataView.getUint16(nameTableOffset + 4, false);
|
||||
|
||||
const userLanguage = getUserLang();
|
||||
const fontNames: Array<{
|
||||
name: string;
|
||||
platformID: number;
|
||||
languageID: number;
|
||||
priority: number;
|
||||
}> = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const recordOffset = nameTableOffset + 6 + i * 12;
|
||||
const platformID = dataView.getUint16(recordOffset, false);
|
||||
const languageID = dataView.getUint16(recordOffset + 4, false);
|
||||
const nameID = dataView.getUint16(recordOffset + 6, false);
|
||||
const nameLength = dataView.getUint16(recordOffset + 8, false);
|
||||
const nameOffsetInTable = dataView.getUint16(recordOffset + 10, false);
|
||||
|
||||
if (nameID === 1) {
|
||||
const stringStart = nameTableOffset + stringOffset + nameOffsetInTable;
|
||||
let familyName = '';
|
||||
|
||||
if (platformID === 0 || platformID === 3) {
|
||||
// Unicode/Microsoft platform
|
||||
familyName = parseUnicodeString(dataView, stringStart, nameLength);
|
||||
} else if (platformID === 1) {
|
||||
// Macintosh platform
|
||||
familyName = parseMacintoshString(dataView, stringStart, nameLength);
|
||||
}
|
||||
|
||||
if (familyName && familyName.trim()) {
|
||||
const priority = getLanguagePriority(platformID, languageID, userLanguage);
|
||||
fontNames.push({
|
||||
name: familyName.trim(),
|
||||
platformID,
|
||||
languageID,
|
||||
priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fontNames.length === 0) {
|
||||
throw new Error('Font family name not found');
|
||||
}
|
||||
fontNames.sort((a, b) => b.priority - a.priority);
|
||||
return fontNames[0]!.name;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse font: ${error}`);
|
||||
return fallbackName;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export const isLanAddress = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const hostname = urlObj.hostname;
|
||||
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for IPv4 private ranges
|
||||
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const match = hostname.match(ipv4Regex);
|
||||
|
||||
if (match) {
|
||||
const [, a, b, c, d] = match.map(Number);
|
||||
if (a === undefined || b === undefined || c === undefined || d === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate IP format
|
||||
if (a > 255 || b > 255 || c > 255 || d > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check private IP ranges:
|
||||
// 10.0.0.0/8 (10.0.0.0 to 10.255.255.255)
|
||||
if (a === 10) return true;
|
||||
|
||||
// 172.16.0.0/12 (172.16.0.0 to 172.31.255.255)
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
|
||||
// 192.168.0.0/16 (192.168.0.0 to 192.168.255.255)
|
||||
if (a === 192 && b === 168) return true;
|
||||
|
||||
// 169.254.0.0/16 (link-local addresses)
|
||||
if (a === 169 && b === 254) return true;
|
||||
|
||||
// Tailscale IPv4 range: 100.64.0.0/10 (100.64.0.0 to 100.127.255.255)
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
}
|
||||
|
||||
// Check for IPv6 private addresses (simplified check)
|
||||
if (hostname.includes(':')) {
|
||||
if (
|
||||
hostname.startsWith('::1') ||
|
||||
hostname.startsWith('fe80:') ||
|
||||
hostname.startsWith('fc00:') ||
|
||||
hostname.startsWith('fd00:')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isContentURI, isFileURI, isValidURL } from './misc';
|
||||
|
||||
export const getFilename = (fileOrUri: string) => {
|
||||
if (isValidURL(fileOrUri) || isContentURI(fileOrUri) || isFileURI(fileOrUri)) {
|
||||
fileOrUri = decodeURI(fileOrUri);
|
||||
}
|
||||
const normalizedPath = fileOrUri.replace(/\\/g, '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
const lastPart = parts.pop()!;
|
||||
return lastPart.split('?')[0]!;
|
||||
};
|
||||
export const getBaseFilename = (filename: string) => {
|
||||
const normalizedPath = filename.replace(/\\/g, '/');
|
||||
const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
|
||||
return baseName;
|
||||
};
|
||||
@@ -82,17 +82,16 @@ const getFontStyles = (
|
||||
font[size="7"] {
|
||||
font-size: ${fontSize * 3}px;
|
||||
}
|
||||
pre, code, kbd {
|
||||
font-family: var(--monospace);
|
||||
}
|
||||
/* hardcoded inline font size */
|
||||
[style*="font-size: 16px"], [style*="font-size:16px"] {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
body * {
|
||||
pre, code, kbd {
|
||||
font-family: var(--monospace);
|
||||
}
|
||||
body *:not(pre):not(code):not(kbd):not(pre *):not(code *):not(kbd *) {
|
||||
${overrideFont ? 'font-family: revert !important;' : ''}
|
||||
}
|
||||
|
||||
`;
|
||||
return fontStyles;
|
||||
};
|
||||
@@ -131,9 +130,6 @@ const getColorStyles = (
|
||||
${overrideColor ? `color: ${primary};` : isDarkMode ? `color: lightblue;` : ''}
|
||||
text-decoration: none;
|
||||
}
|
||||
p:has(img), span:has(img) {
|
||||
background-color: ${bg};
|
||||
}
|
||||
body.pbg {
|
||||
${isDarkMode ? `background-color: ${bg} !important;` : ''}
|
||||
}
|
||||
@@ -141,8 +137,8 @@ const getColorStyles = (
|
||||
${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''}
|
||||
${!isDarkMode && overrideColor ? 'mix-blend-mode: multiply;' : ''}
|
||||
}
|
||||
/* horizontal rule */
|
||||
*:has(hr) {
|
||||
/* horizontal rule #1649 */
|
||||
*:has(> hr[class]):not(body) {
|
||||
background-color: ${bg};
|
||||
}
|
||||
hr {
|
||||
@@ -324,7 +320,7 @@ const getLayoutStyles = (
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.duokan-footnote img {
|
||||
.duokan-footnote img:not([class]) {
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
}
|
||||
@@ -497,14 +493,12 @@ export const transformStylesheet = (vw: number, vh: number, css: string) => {
|
||||
const hasTextAlignCenter = /text-align\s*:\s*center\s*[;$]/.test(block);
|
||||
const hasTextIndentZero = /text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?\s*[;$]/.test(block);
|
||||
|
||||
if (hasTextAlignCenter) {
|
||||
if (hasTextAlignCenter && hasTextIndentZero) {
|
||||
block = block.replace(/(text-align\s*:\s*center)(\s*;|\s*$)/g, '$1 !important$2');
|
||||
if (hasTextIndentZero) {
|
||||
block = block.replace(
|
||||
/(text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?)(\s*;|\s*$)/g,
|
||||
'$1 !important$2',
|
||||
);
|
||||
}
|
||||
block = block.replace(
|
||||
/(text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?)(\s*;|\s*$)/g,
|
||||
'$1 !important$2',
|
||||
);
|
||||
return selector + block;
|
||||
}
|
||||
return match;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { partialMD5 } from './md5';
|
||||
import { getBaseFilename } from './book';
|
||||
import { getBaseFilename } from './path';
|
||||
import { detectLanguage } from './lang';
|
||||
|
||||
interface Metadata {
|
||||
|
||||
@@ -63,7 +63,7 @@ function ReadestSync:tryRefreshToken()
|
||||
self.settings.refresh_token = response.refresh_token
|
||||
self.settings.expires_at = response.expires_at
|
||||
self.settings.expires_in = response.expires_in
|
||||
G_reader_settings:writeSetting("readest_sync", self.settings)
|
||||
G_reader_settings:saveSetting("readest_sync", self.settings)
|
||||
else
|
||||
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
|
||||
end
|
||||
@@ -229,8 +229,8 @@ function ReadestSync:doLogin(email, password, menu)
|
||||
self.settings.refresh_token = response.refresh_token
|
||||
self.settings.expires_at = response.expires_at
|
||||
self.settings.expires_in = response.expires_in
|
||||
G_reader_settings:writeSetting("readest_sync", self.settings)
|
||||
|
||||
G_reader_settings:saveSetting("readest_sync", self.settings)
|
||||
|
||||
if menu then
|
||||
menu:updateItems()
|
||||
end
|
||||
@@ -261,7 +261,7 @@ function ReadestSync:logout(menu)
|
||||
self.settings.refresh_token = nil
|
||||
self.settings.expires_at = nil
|
||||
self.settings.expires_in = nil
|
||||
G_reader_settings:writeSetting("readest_sync", self.settings)
|
||||
G_reader_settings:saveSetting("readest_sync", self.settings)
|
||||
|
||||
if menu then
|
||||
menu:updateItems()
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"base_url": "http://web.readest.com/api",
|
||||
"base_url": "https://web.readest.com/api",
|
||||
"name": "readest-sync-api",
|
||||
"methods": {
|
||||
"pullChanges": {
|
||||
"path": "/sync",
|
||||
"method": "GET",
|
||||
"required_params": ["since", "type", "book"],
|
||||
"expected_status": [200, 400, 401, 403]
|
||||
"expected_status": [200, 400, 301, 401, 403]
|
||||
},
|
||||
"pushChanges": {
|
||||
"path": "/sync",
|
||||
"method": "POST",
|
||||
"required_params": ["books", "notes", "configs"],
|
||||
"payload": ["books", "notes", "configs"],
|
||||
"expected_status": [200, 201, 400, 401, 403]
|
||||
"expected_status": [200, 201, 301, 400, 401, 403]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 655100371f...db24aa01cb
+1
-1
Submodule packages/tauri updated: 3539f64bfb...7c4d34b6dd
+1
-1
Submodule packages/tauri-plugins updated: 42f62f9d78...75617a6a92
Generated
+227
-265
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user