Compare commits

...

25 Commits

Author SHA1 Message Date
Huang Xin 3350bdbdd3 release: version 0.9.71 (#1787) 2025-08-12 18:59:04 +02:00
Huang Xin 634d789654 css: fix initial text align (#1786) 2025-08-12 18:37:00 +02:00
Huang Xin 0dfa8e96f5 feat(sync): add readest koplugin for progress sync, also implements #1729 (#1785) 2025-08-12 18:24:47 +02:00
Huang Xin 3698e6ca28 sync: refactor koreader sync settings (#1783)
* sync: refactor koreader sync settings

* i18n: added Tibetan translations, closes #1780
2025-08-11 19:41:39 +02:00
Zeedif 595608bd62 feat: Implement KOReader Progress Synchronization (#1770)
* feat(sync): implement KOReader progress synchronization

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

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

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

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

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

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

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

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

* i18n: update translations

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-08-11 17:53:28 +02:00
Huang Xin b8bb1ee71d fix(sync): correct xpointer spine index and omit certain tag index (#1781) 2025-08-11 15:25:16 +02:00
arutonee1 4298213ce4 feat: added option to delete only the local copy of a book (#1773)
* feat: added button to delete the local copy of a book

* refactor: merged deletion api and messages

* fix: included translations for deletion messages

* updated translations

* refactor the deletion dropdown menu

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-08-11 10:38:34 +02:00
Huang Xin 5671460618 sync: add xcfi to convert between readest epubcfi and koreader xpointer (#1774) 2025-08-09 19:01:54 +02:00
Huang Xin 452dc9f3e6 chore: cache nextjs build for CI (#1771) 2025-08-09 07:28:38 +02:00
Andres Puello 09e65211b4 feat: add option to config reading progress format (#1761)
* feat: added percentage option and set by default

* feat: added decimal percentaje

* fix: fixed issue that the function of format didn't look for typeof

* fix: Added revision fixes

* fix: Added revision fixes and changes

* fix: solved about styling code

* fix: solved about styling code on book.ts and translation.json

* fix: changed lines useEffect line that is escencial to Layoutpanel.tsx, and ProgressInfo.tsx has correct render like last version (Loc.) nd its separator

* fix: correct SUPPORTED_LANGS key and disable style reapplication in saveViewSettings

* i18n for the progress style config

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-08-09 06:23:18 +02:00
Huang Xin f46be89036 fix: apply system color scheme for iOS in auto theme mode, closes #1762 (#1767) 2025-08-08 13:45:04 +02:00
Huang Xin f2f744a2fe css: mix with background color for horizontal rule, closes #1649 (#1759) 2025-08-07 11:18:56 +02:00
Huang Xin ceb1963b88 auth: avoid infinite loop of redirect and auth after token is expired (#1758) 2025-08-07 05:40:13 +02:00
Huang Xin 23d74b567a css: apply monospace font settings for EPUBs, closes #1754 (#1757) 2025-08-07 03:54:44 +02:00
StepanSad 5014932a8f i18n: Update translation.json (#1756) 2025-08-07 07:20:14 +08:00
Huang Xin 6a5694d2b2 feat: readest koplugin for progress sync (#1753) 2025-08-06 17:12:00 +02:00
Huang Xin df074082a8 fix: thread-safe NativeFile read for PDF on Android, closes #1748 (#1751) 2025-08-06 16:24:56 +02:00
Huang Xin 847d514441 fix: NativeFile access for file URI when importing files on iOS, closes #1746 (#1747) 2025-08-05 18:48:42 +02:00
Huang Xin b2a71da2b0 fix: chunk of cache item now has enough space for read of MAX_CACHE_CHUNK_SIZE, closes #1392 (#1744) 2025-08-04 16:35:27 +02:00
Huang Xin 3e5e4d2946 compat: detect book language from text for invalid language code in metadata (#1743) 2025-08-04 14:55:18 +02:00
Huang Xin e3c05e7648 feat: add reset password button in user page (#1742) 2025-08-04 14:12:19 +02:00
Huang Xin df9ca2d2fa api: migrate usage stats from KV to db (#1739) 2025-08-03 05:36:33 +02:00
Huang Xin 1631ece2bd docs: doc submodule update as dependencies 2025-08-03 03:39:24 +02:00
Huang Xin 2ae25f3bbe translator: add more translator languages (#1738) 2025-08-02 17:09:09 +02:00
Huang Xin 511093a6ef tts: tts now works when language codes are mistakenly set with language names (#1737) 2025-08-02 16:52:32 +02:00
99 changed files with 5470 additions and 419 deletions
+9 -2
View File
@@ -45,7 +45,7 @@ jobs:
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.1
version: 10.14.0
- name: setup node
uses: actions/setup-node@v4
@@ -53,6 +53,13 @@ jobs:
node-version: 22
cache: pnpm
- name: cache Next.js build
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 }}-
- name: install Dependencies
working-directory: apps/readest-app
run: |
@@ -61,7 +68,7 @@ jobs:
- name: run tests
working-directory: apps/readest-app
run: |
pnpm test -- --watchAll=false
pnpm test -- --watch=false
- name: build the web App
if: matrix.config.platform == 'web'
+1 -1
View File
@@ -99,7 +99,7 @@ jobs:
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.14.4
version: 10.14.0
- name: setup node
uses: actions/setup-node@v4
+1
View File
@@ -5,5 +5,6 @@
"tabWidth": 2,
"singleQuote": true,
"jsxSingleQuote": true,
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}
+1 -1
View File
@@ -136,13 +136,13 @@ To get started with Readest, follow these steps to clone and build the project.
```bash
git clone https://github.com/readest/readest.git
cd readest
git submodule update --init --recursive
```
### 2. Install Dependencies
```bash
# might need to rerun this when code is updated
git submodule update --init --recursive
pnpm install
# copy pdfjs-dist to Next.js public directory
pnpm --filter @readest/readest-app setup-pdfjs
@@ -27,6 +27,7 @@ module.exports = {
'vi',
'ar',
'th',
'bo',
'zh-CN',
'zh-TW',
],
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.69",
"version": "0.9.71",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -38,7 +38,8 @@
"upload": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"check:optional-chaining": "count=$(grep -rno '\\?\\.[a-zA-Z_$]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Optional chaining found in output!'; exit 1; else echo '✅ No optional chaining found.'; fi",
"check:all": "pnpm check:optional-chaining",
"check:translations": "count=$(grep -rno '__STRING_NOT_TRANSLATED__' public/locales/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Untranslated strings found!'; exit 1; else echo '✅ All strings translated.'; fi",
"check:all": "pnpm check:optional-chaining && pnpm check:translations",
"build-check": "pnpm build && pnpm build-web && pnpm check:all"
},
"dependencies": {
@@ -97,6 +98,7 @@
"semver": "^7.7.1",
"stripe": "^18.2.1",
"tinycolor2": "^1.6.0",
"uuid": "^11.1.0",
"zod": "^4.0.8",
"zustand": "5.0.6"
},
@@ -115,6 +117,7 @@
"@types/react-window": "^1.8.8",
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
@@ -94,7 +94,6 @@
"Speak": "تحدث",
"Subjects": "المواضيع",
"System Fonts": "خطوط النظام",
"TTS not supported in this device": "القراءة الصوتية غير مدعومة في هذا الجهاز",
"Theme Color": "لون السمة",
"Theme Mode": "وضع السمة",
"Translate": "ترجمة",
@@ -319,7 +318,6 @@
"Synced at {{time}}": "تمت المزامنة في {{time}}",
"Never synced": "لم تتم المزامنة بعد",
"Show Remaining Time": "إظهار الوقت المتبقي",
"Show Page Number": "إظهار رقم الصفحة",
"{{time}} min left in chapter": "{{time}} دقيقة متبقية حتى نهاية الفصل",
"Override Book Color": "تجاوز لون الكتاب",
"Login Required": "يتطلب الأمر تسجيل الدخول",
@@ -427,7 +425,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 5 جيجابايت تخزين سحابي آمن.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 20 جيجابايت تخزين سحابي آمن.",
"Deleted cloud backup of the book: {{title}}": "تم حذف النسخة الاحتياطية السحابية للكتاب: {{title}}",
"Failed to delete cloud backup of the book": "فشل في حذف النسخة الاحتياطية السحابية للكتاب",
"Are you sure to delete the cloud backup of the selected book?": "هل أنت متأكد من حذف النسخة الاحتياطية السحابية للكتاب المحدد؟",
"What's New in Readest": "ما الجديد في Readest",
"Enter book title": "أدخل عنوان الكتاب",
@@ -477,5 +474,51 @@
"Storage": "التخزين",
"Terms of Service": "شروط الخدمة",
"Privacy Policy": "سياسة الخصوصية",
"Disable Double Click": "تعطيل النقر المزدوج"
"Disable Double Click": "تعطيل النقر المزدوج",
"TTS not supported for this document": "تكنولوجيا تحويل النص إلى كلام غير مدعومة لهذا المستند.",
"Reset Password": "إعادة تعيين",
"Show Reading Progress": "إظهار تقدم القراءة",
"Reading Progress Style": "نمط تقدم القراءة",
"Page Number": "رقم الصفحة",
"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": "غير متصل",
"KOReader Sync Settings": "إعدادات مزامنة KOReader",
"Sync Strategy": "استراتيجية المزامنة",
"Ask on conflict": "اسأل عند حدوث تعارض",
"Always use latest": "استخدام الأحدث دائمًا",
"Send changes only": "إرسال التغييرات فقط",
"Receive changes only": "استلام التغييرات فقط",
"Disabled": "معطل",
"Checksum Method": "طريقة التحقق من الصحة",
"File Content (recommended)": "محتوى الملف (موصى به)",
"File Name": "اسم الملف",
"Device Name": "اسم الجهاز",
"Sync Tolerance": "تحمل المزامنة",
"Connect to your KOReader Sync server.": "الاتصال بخادم مزامنة KOReader الخاص بك.",
"Server URL": "عنوان URL للخادم",
"Username": "اسم المستخدم",
"Your Username": "اسم المستخدم الخاص بك",
"Password": "كلمة المرور",
"Connect": "الاتصال",
"KOReader Sync": "مزامنة KOReader",
"Sync Conflict": "تعارض المزامنة",
"Sync reading progress from \"{{deviceName}}\"?": "مزامنة تقدم القراءة من \"{{deviceName}}\"؟",
"another device": "جهاز آخر",
"Local Progress": "التقدم المحلي",
"Remote Progress": "التقدم البعيد",
"Page {{page}} of {{total}} ({{percentage}}%)": "الصفحة {{page}} من {{total}} ({{percentage}}%)",
"Current position": "الموقع الحالي",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "تقريبًا الصفحة {{page}} من {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "تقريبًا {{percentage}}%",
"Failed to connect": "فشل في الاتصال",
"Sync Server Connected": "تم الاتصال بخادم المزامنة",
"Precision: {{precision}} digits after the decimal": "الدقة: {{precision}} أرقام بعد الفاصلة",
"Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}"
}
@@ -0,0 +1,504 @@
{
"(detected)": "(བརྟག་དཔྱད་བྱུང་བ།)",
"About Readest": "Readest སྐོར།",
"Add your notes here...": "འདིར་ཁྱེད་ཀྱི་ཟིན་བྲིས་འདེབས་དང་།...",
"Animation": "འགུལ་རིས།",
"Annotate": "ཟིན་བྲིས།",
"Apply": "སྤྱོད་པ།",
"Auto Mode": "རང་འགུལ་བརྗོད་བྱ།",
"Behavior": "བྱ་སྤྱོད།",
"Book": "དཔེ་དེབ།",
"Book Cover": "དཔེ་དེབ་ཀྱི་སྟོང་ངོས།",
"Bookmark": "དཔེ་རྟགས།",
"Cancel": "འདོར་བ།",
"Chapter": "ལེའུ།",
"Cherry": "ཆུ་འབྲས་མདོག",
"Color": "ཚོན་མདོག",
"Confirm": "གཏན་འཁེལ།",
"Confirm Deletion": "བསུབ་པ་གཏན་འཁེལ།",
"Copied to notebook": "ཟིན་དེབ་ཏུ་འདྲ་བཤུས་བྱས་ཟིན།",
"Copy": "འདྲ་བཤུས།",
"Dark Mode": "མདོག་ནག་པོའི་བརྗོད་བྱ།",
"Default": "སྔོན་སྒྲིག",
"Default Font": "སྔོན་སྒྲིག་ཡིག་གཟུགས།",
"Default Font Size": "སྔོན་སྒྲིག་ཡིག་ཆེ་ཆུང་།",
"Delete": "བསུབ་པ།",
"Delete Highlight": "འོག་ཐིག་བསུབ་པ།",
"Dictionary": "ཚིག་མཛོད།",
"Download Readest": "Readest ཕབ་ལེན།",
"Edit": "རྩོམ་སྒྲིག",
"Excerpts": "དྲངས་བཏུས།",
"Failed to import book(s): {{filenames}}": "དཔེ་དེབ་ནང་འདྲེན་བྱས་མ་ཐུབ། {{filenames}}",
"Fast": "མགྱོགས་པོ།",
"Font": "ཡིག་གཟུགས།",
"Font & Layout": "ཡིག་གཟུགས་དང་བཀོད་སྒྲིག",
"Font Face": "ཡིག་གཟུགས་ཀྱི་རྣམ་པ།",
"Font Family": "ཡིག་རིགས།",
"Font Size": "ཡིག་ཆེ་ཆུང་།",
"Full Justification": "གཡས་གཡོན་སྙོམས་པ།",
"Global Settings": "ཁྱོན་ཡོངས་སྒྲིག་བཀོད།",
"Go Back": "ཕྱིར་ལོག",
"Go Forward": "མདུན་སྐྱོད།",
"Grass": "རྩྭ་ལྗང་ཁུ།",
"Gray": "སྙིང་རུས་ཆུང་བ།",
"Gruvbox": "དྲོད་ལྡན་གོང་མདོག",
"Highlight": "འོག་ཐིག",
"Horizontal Direction": "འཕྲེད་རིམ།",
"Hyphenation": "ཡི་གེ་གཤགས་པ།",
"Import Books": "དཔེ་དེབ་ནང་འདྲེན།",
"Layout": "བཀོད་སྒྲིག",
"Light Mode": "མདོག་སྐྱ་བོའི་བརྗོད་བྱ།",
"Loading...": "འགུལ་སྐྱོང་བྱེད་བཞིན་པ།...",
"Logged in": "ནང་བསྐྱོད་བྱས་ཟིན།",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} ནང་བསྐྱོད་བྱས་ཟིན།",
"Match Case": "ཡི་གེ་ཆེ་ཆུང་གི་དོ་མཚུངས།",
"Match Diacritics": "སྒྲ་གདངས་ཀྱི་དོ་མཚུངས།",
"Match Whole Words": "ཚིག་བྱང་ཆ་ཚང་གི་དོ་མཚུངས།",
"Maximum Number of Columns": "གྲལ་ཐིག་གི་གྲངས་ཀ།",
"Minimum Font Size": "ཡིག་ཆུང་ཤོས།",
"Monospace Font": "རྒྱ་ཁྱོན་འདྲ་བའི་ཡིག་གཟུགས།",
"More Info": "ཆ་འཕྲིན་དེ་བས།",
"Nord": "འོད་ཟེར་སྔོན་པོ།",
"Notebook": "ཟིན་དེབ།",
"Notes": "ཟིན་བྲིས།",
"Open": "ཁ་ཕྱེ་བ།",
"Original Text": "མགོ་ཡིག",
"Page": "ཤོག་ངོས།",
"Paging Animation": "ཤོག་ལྷེ་བསྒྱུར་བའི་འགུལ་རིས།",
"Paragraph": "དོན་ཚན།",
"Parallel Read": "གྲལ་སྤྲོད་ཀྱིས་ཀློག་པ།",
"Published": "དཔེ་སྐྲུན་བྱས་པའི་ཚེས་གྲངས།",
"Publisher": "དཔེ་སྐྲུན་ཁང་།",
"Reading Progress Synced": "ཀློག་པའི་ཡར་འཕེལ་སྐད་མཉམ་བྱས་ཟིན།",
"Reload Page": "ཤོག་ངོས་བསྐྱར་འགུལ་སྐྱོང་བྱ་དགོས།",
"Reveal in File Explorer": "ཡིག་ཆ་དོ་དམ་ཆས་ནང་མངོན་པ།",
"Reveal in Finder": "བལྟ་ཞིབ་ཆས་ནང་མངོན་པ།",
"Reveal in Folder": "ཡིག་ཁུག་ནང་མངོན་པ།",
"Sans-Serif Font": "མཐའ་ཐིག་མེད་པའི་ཡིག་གཟུགས།",
"Save": "ཉར་ཚགས།",
"Scrolled Mode": "འཁོར་རྒྱུག་རྣམ་པ།",
"Search": "འཚོལ་བཤེར།",
"Search Books...": "དཔེ་དེབ་འཚོལ་བཤེར།...",
"Search...": "འཚོལ་བཤེར།...",
"Select Book": "དཔེ་དེབ་འདེམས་པ།",
"Select Books": "དཔེ་དེབ་འདེམས་པ།",
"Select Multiple Books": "དཔེ་དེབ་མང་པོ་འདེམས་པ།",
"Sepia": "རྙིང་པའི་ཉམས་འགྱུར།",
"Serif Font": "མཐའ་ཐིག་ཡོད་པའི་ཡིག་གཟུགས།",
"Show Book Details": "དཔེ་དེབ་ཀྱི་གནས་ཚུལ་རྒྱས་པ་མངོན་པ།",
"Sidebar": "ཟུར་ངོས་ཀྱི་གྲལ་ཐིག",
"Sign In": "ནང་བསྐྱོད།",
"Sign Out": "ཕྱིར་བུད།",
"Sky": "མཁའ་དཀར།",
"Slow": "དལ་བ།",
"Solarized": "ཉི་འོད།",
"Speak": "སྒྲ་ཀློག",
"Subjects": "བརྗོད་བྱ།",
"System Fonts": "མ་ལག་གི་ཡིག་གཟུགས།",
"Theme Color": "བརྗོད་བྱའི་ཚོན་མདོག",
"Theme Mode": "བརྗོད་བྱའི་རྣམ་པ།",
"Translate": "སྒྱུར་བཅོས།",
"Translated Text": "སྒྱུར་ཡིག",
"Unknown": "མི་ཤེས་པ།",
"Untitled": "མགོ་མེད།",
"Updated": "བསྐྱར་བརྗེའི་ཚེས་གྲངས།",
"Version {{version}}": "ཐོན་རིམ {{version}}",
"Vertical Direction": "ཀྲིང་རིམ།",
"Welcome to your library. You can import your books here and read them anytime.": "དཔེ་མཛོད་སྟོང་པ་ཡིན། ཁྱེད་ཀྱིས་ཁྱེད་རང་གི་དཔེ་དེབ་ནང་འདྲེན་བྱས་ནས་དུས་དང་རྣམ་པ་ཀུན་ཏུ་ཀློག་ཐུབ།",
"Wikipedia": "ཝེ་ཁི་པི་ཌི་ཡ།",
"Writing Mode": "པར་སྒྲིག་གི་རྣམ་པ།",
"Your Library": "དཔེ་མཛོད།",
"TTS not supported for PDF": "PDF ཡིག་ཆར་ TTS རྒྱབ་སྐྱོར་མི་བྱེད།",
"Override Book Font": "དཔེ་དེབ་ཀྱི་ཡིག་གཟུགས་བརྗེ་བ།",
"Apply to All Books": "དཔེ་དེབ་ཡོད་རྒུར་སྤྱོད་པ།",
"Apply to This Book": "དཔེ་དེབ་འདིར་སྤྱོད་པ།",
"Unable to fetch the translation. Try again later.": "སྒྱུར་བཅོས་ལེན་ཐུབ་མེད། ཅུང་ཙམ་སྒུག་ནས་ཡང་བསྐྱར་ཚོད་ལྟ་བྱེད་རོགས།",
"Check Update": "བསྐྱར་བརྗེ་བརྟག་དཔྱད།",
"Already the latest version": "ད་ལྟ་ཐོན་རིམ་གསར་ཤོས་ཡིན།",
"Book Details": "དཔེ་དེབ་ཀྱི་གནས་ཚུལ་རྒྱས་པ།",
"From Local File": "ས་གནས་ཀྱི་ཡིག་ཆ་ནས་ནང་འདྲེན།",
"TOC": "དཀར་ཆག",
"Book uploaded: {{title}}": "དཔེ་དེབ་སྤྲད་ཟིན། {{title}}",
"Failed to upload book: {{title}}": "དཔེ་དེབ་སྤྲད་མ་ཐུབ། {{title}}",
"Book downloaded: {{title}}": "དཔེ་དེབ་ཕབ་ལེན་བྱས་ཟིན། {{title}}",
"Failed to download book: {{title}}": "དཔེ་དེབ་ཕབ་ལེན་བྱས་མ་ཐུབ། {{title}}",
"Upload Book": "དཔེ་དེབ་སྤྲད་པ།",
"Auto Upload Books to Cloud": "དཔེ་དེབ་རང་འགུལ་གྱིས་སྤྲི་དོན་ལ་སྤྲད་པ།",
"Book deleted: {{title}}": "དཔེ་དེབ་བསུབ་ཟིན། {{title}}",
"Failed to delete book: {{title}}": "དཔེ་དེབ་བསུབ་མ་ཐུབ། {{title}}",
"Check Updates on Start": "འགོ་སློང་སྐབས་བསྐྱར་བརྗེ་བརྟག་དཔྱད།",
"Insufficient storage quota": "སྤྲི་དོན་གྱི་ཉར་ཚགས་ས་ཁོངས་མི་འདང་།",
"Font Weight": "ཡིག་གཟུགས་ཀྱི་ལྗིད་ཚད།",
"Line Spacing": "ལོག་བར་གྱི་བར་ཐག",
"Word Spacing": "ཚིག་བར་གྱི་བར་ཐག",
"Letter Spacing": "ཡི་གེ་བར་གྱི་བར་ཐག",
"Text Indent": "ལོག་ཐོག་མའི་ནང་དུ་བསྐྱུར་བ།",
"Paragraph Margin": "དོན་ཚན་བར་གྱི་བར་ཐག",
"Override Book Layout": "པར་ངོས་ཀྱི་བཀོད་སྒྲིག་བརྗེ་བ།",
"Untitled Group": "མགོ་མེད་སྡེ་ཚན།",
"Group Books": "དཔེ་དེབ་སྡེ་ཚན་དུ་བསྡུ་བ།",
"Remove From Group": "སྡེ་ཚན་ནས་ཕྱིར་འཐེན།",
"Create New Group": "སྡེ་ཚན་གསར་པ་བཟོ་བ།",
"Deselect Book": "དཔེ་དེབ་འདེམས་པ་འདོར་བ།",
"Download Book": "དཔེ་དེབ་ཕབ་ལེན།",
"Deselect Group": "སྡེ་ཚན་འདེམས་པ་འདོར་བ།",
"Select Group": "སྡེ་ཚན་འདེམས་པ།",
"Keep Screen Awake": "བརྡ་ཁྱབ་ངོས་རྟག་ཏུ་འོད་ཟེར་འཕྲོ་བ།",
"Email address": "གློག་རྡུལ་ཡིག་ཟམ་གྱི་ཁ་བྱང་།",
"Your Password": "ཁྱེད་ཀྱི་གསང་ཨང་།",
"Your email address": "ཁྱེད་ཀྱི་གློག་རྡུལ་ཡིག་ཟམ་གྱི་ཁ་བྱང་།",
"Your password": "ཁྱེད་ཀྱི་གསང་ཨང་།",
"Sign in": "ནང་བསྐྱོད།",
"Signing in...": "ནང་བསྐྱོད་བྱེད་བཞིན་པ།...",
"Sign in with {{provider}}": "{{provider}} བེད་སྤྱད་ནས་ནང་བསྐྱོད།",
"Already have an account? Sign in": "གོ་མིང་ཡོད་ན་ནང་བསྐྱོད་བྱོས།",
"Create a Password": "གསང་ཨང་བཟོ་བ།",
"Sign up": "ཐོ་འགོད།",
"Signing up...": "ཐོ་འགོད་བྱེད་བཞིན་པ།...",
"Don't have an account? Sign up": "གོ་མིང་མེད་ན་ཐོ་འགོད་བྱོས།",
"Check your email for the confirmation link": "ཁྱེད་ཀྱི་གློག་རྡུལ་ཡིག་ཟམ་ནས་གཏན་འཁེལ་གྱི་སྦྲེལ་ཐག་ལ་བལྟ་དགོས།",
"Signing in ...": "ནང་བསྐྱོད་བྱེད་བཞིན་པ།...",
"Send a magic link email": "བྱ་བྱེད་སྦྲེལ་ཐག་གི་གློག་རྡུལ་ཡིག་ཟམ་སྤྲད་པ།",
"Check your email for the magic link": "ཁྱེད་ཀྱི་གློག་རྡུལ་ཡིག་ཟམ་ནས་བྱ་བྱེད་སྦྲེལ་ཐག་ལ་བལྟ་དགོས།",
"Send reset password instructions": "གསང་ཨང་བསྐྱར་སྒྲིག་གི་འགྲེལ་བཤད་སྤྲད་པ།",
"Sending reset instructions ...": "གསང་ཨང་བསྐྱར་སྒྲིག་གི་འགྲེལ་བཤད་སྤྲད་བཞིན་པ།...",
"Forgot your password?": "གསང་ཨང་བརྗེད་སོང་ངམ།",
"Check your email for the password reset link": "ཁྱེད་ཀྱི་གློག་རྡུལ་ཡིག་ཟམ་ནས་གསང་ཨང་བསྐྱར་སྒྲིག་གི་སྦྲེལ་ཐག་ལ་བལྟ་དགོས།",
"New Password": "གསང་ཨང་གསར་པ།",
"Your new password": "ཁྱེད་ཀྱི་གསང་ཨང་གསར་པ།",
"Update password": "གསང་ཨང་བསྐྱར་བརྗེ།",
"Updating password ...": "གསང་ཨང་བསྐྱར་བརྗེ་བཞིན་པ།...",
"Your password has been updated": "ཁྱེད་ཀྱི་གསང་ཨང་བསྐྱར་བརྗེ་ཟིན།",
"Phone number": "ཁ་པར་ཨང་གྲངས།",
"Your phone number": "ཁྱེད་ཀྱི་ཁ་པར་ཨང་གྲངས།",
"Token": "བཀོལ་ཐོགས།",
"Your OTP token": "ཁྱེད་ཀྱི་ OTP བཀོལ་ཐོགས།",
"Verify token": "བཀོལ་ཐོགས་བདེན་སྦྱོང་།",
"Sign in with Google": "Google བེད་སྤྱད་ནས་ནང་བསྐྱོད།",
"Sign in with Apple": "Apple བེད་སྤྱད་ནས་ནང་བསྐྱོད།",
"Sign in with GitHub": "GitHub བེད་སྤྱད་ནས་ནང་བསྐྱོད།",
"Account": "རྩིས་ཐོ།",
"Failed to delete user. Please try again later.": "སྤྱོད་མཁན་བསུབ་མ་ཐུབ། ཅུང་ཙམ་སྒུག་ནས་ཡང་བསྐྱར་ཚོད་ལྟ་བྱེད་རོགས།",
"Community Support": "སྡེ་ཁུལ་གྱི་རྒྱབ་སྐྱོར།",
"Priority Support": "གཙོ་རིམ་གྱི་རྒྱབ་སྐྱོར།",
"Loading profile...": "མི་སྒེར་གྱི་ཆ་འཕྲིན་འགུལ་སྐྱོང་བྱེད་བཞིན་པ།...",
"Delete Account": "རྩིས་ཐོ་བསུབ་པ།",
"Delete Your Account?": "ཁྱེད་ཀྱི་རྩིས་ཐོ་བསུབ་དགོས་སམ།",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "བྱ་སྤྱོད་འདི་ཕྱིར་འཐེན་བྱེད་ཐུབ་མེད། ཁྱེད་ཀྱིས་སྤྲི་དོན་ཐོག་གི་གཞི་གྲངས་ཡོད་རྒུ་རྟག་ཏུ་བསུབ་པར་འགྱུར།",
"Delete Permanently": "རྟག་ཏུ་བསུབ་པ།",
"RTL Direction": "གཡས་ནས་གཡོན་དུ།",
"Maximum Column Height": "གྲལ་ཐིག་མཐོ་ཤོས།",
"Maximum Column Width": "གྲལ་ཐིག་རྒྱ་ཆེ་ཤོས།",
"Continuous Scroll": "རྒྱུན་མཐུད་འཁོར་རྒྱུག",
"Fullscreen": "བརྡ་ཁྱབ་ངོས་ཆ་ཚང་།",
"No supported files found. Supported formats: {{formats}}": "རྒྱབ་སྐྱོར་བྱེད་པའི་ཡིག་ཆ་མ་རྙེད། རྒྱབ་སྐྱོར་བྱེད་པའི་རྣམ་པ། {{formats}}",
"Drop to Import Books": "འདྲུད་འཇོག་བྱས་ནས་དཔེ་དེབ་ནང་འདྲེན།",
"Custom": "རང་འགུལ་གྱིས་བཟོ་བ།",
"Custom Theme": "རང་འགུལ་གྱི་བརྗོད་བྱ།",
"Theme Name": "བརྗོད་བྱའི་མིང་།",
"Text Color": "ཡི་གེའི་ཚོན་མདོག",
"Background Color": "རྒྱབ་ལྗོངས་ཀྱི་ཚོན་མདོག",
"Preview": "སྔོན་ལྟ།",
"Contrast": "བསྡུར་བ།",
"Sunset": "ཉི་མ་ནུབ་པ།",
"Double Border": "ཤོག་ལྷེ་མངོན་པ།",
"Border Color": "ཤོག་ལྷེའི་ཚོན་མདོག",
"Border Frame": "ཤོག་ངོས་ཀྱི་ཤོག་ལྷེ།",
"Show Header": "ཤོག་ངོས་ཀྱི་མགོ་མངོན་པ།",
"Show Footer": "ཤོག་ངོས་ཀྱི་མཇུག་མངོན་པ།",
"Small": "ཆུང་བ།",
"Large": "ཆེ་བ།",
"Auto": "རང་འགུལ།",
"Language": "སྐད་ཡིག",
"No annotations to export": "ཕྱིར་འདོན་བྱ་རྒྱུའི་ཟིན་བྲིས་མེད།",
"Author": "རྩོམ་པ་པོ།",
"Exported from Readest": "Readest ནས་ཕྱིར་འདོན་བྱས་པ།",
"Highlights & Annotations": "འོག་ཐིག་དང་ཟིན་བྲིས།",
"Note": "ཟིན་བྲིས།",
"Copied to clipboard": "འདྲ་བཤུས་ལྡེ་མིག་ཏུ་བྱས་ཟིན།",
"Export Annotations": "ཟིན་བྲིས་ཕྱིར་འདོན།",
"Auto Import on File Open": "ཡིག་ཆ་ཁ་ཕྱེ་སྐབས་རང་འགུལ་གྱིས་ནང་འདྲེན།",
"LXGW WenKai GB Screen": "ཤ་ཝུ་ཝུན་ཁའེ།",
"LXGW WenKai TC": "ཤ་ཝུ་ཝུན་ཁའེ།",
"No chapters detected.": "ལེའུ་བརྟག་དཔྱད་བྱུང་མེད།",
"Failed to parse the EPUB file.": "EPUB ཡིག་ཆ་འགྲེལ་བཤད་བྱེད་ཐུབ་མེད།",
"This book format is not supported.": "དཔེ་དེབ་ཀྱི་རྣམ་པ་འདི་རྒྱབ་སྐྱོར་མི་བྱེད།",
"Unable to fetch the translation. Please log in first and try again.": "སྒྱུར་བཅོས་ལེན་ཐུབ་མེད། ནང་བསྐྱོད་བྱས་རྗེས་ཡང་བསྐྱར་ཚོད་ལྟ་བྱེད་རོགས།",
"Group": "སྡེ་ཚན།",
"Always on Top": "སྒེའུ་ཁུང་གནས་སུ་བཀོད་པ།",
"No Timeout": "དུས་ཚོད་བཀག་པ།",
"{{value}} minute": "{{value}}སྐར་མ།",
"{{value}} minutes": "{{value}}སྐར་མ།",
"{{value}} hour": "{{value}}ཆུ་ཚོད།",
"{{value}} hours": "{{value}}ཆུ་ཚོད།",
"CJK Font": "རྒྱ་ཡིག་གི་ཡིག་གཟུགས།",
"Clear Search": "འཚོལ་བཤེར་བསལ་བ།",
"Header & Footer": "ཤོག་ངོས་ཀྱི་མགོ་དང་མཇུག",
"Apply also in Scrolled Mode": "འཁོར་རྒྱུག་རྣམ་པར་སྤྱོད་པ།",
"A new version of Readest is available!": "Readest ཐོན་རིམ་གསར་པ་བསྐྱར་བརྗེ་བྱ་རྒྱུ་ཡོད།",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} བསྐྱར་བརྗེ་བྱ་རྒྱུ་ཡོད།(བསྒྲིགས་ཟིན་པའི་ཐོན་རིམ {{currentVersion}}",
"Download and install now?": "ད་ལྟ་ཕབ་ལེན་དང་བསྒྲིགས་དགོས་སམ།",
"Downloading {{downloaded}} of {{contentLength}}": "ཕབ་ལེན་བྱེད་བཞིན་པ། {{downloaded}} / {{contentLength}}",
"Download finished": "ཕབ་ལེན་བྱས་ཚར།",
"DOWNLOAD & INSTALL": "ཕབ་ལེན་དང་བསྒྲིགས་པ།",
"Changelog": "བསྐྱར་བརྗེའི་ཟིན་ཐོ།",
"Software Update": "མཉེན་ཆས་བསྐྱར་བརྗེ།",
"Title": "དཔེ་དེབ་ཀྱི་མིང་།",
"Date Read": "ཀློག་པའི་ཚེས་གྲངས།",
"Date Added": "ཁ་སྣོན་བྱས་པའི་ཚེས་གྲངས།",
"Format": "རྣམ་པ།",
"Ascending": "འཕར་རིམ།",
"Descending": "འཕྲི་རིམ།",
"Sort by...": "རིམ་སྒྲིག་བྱེད་སྟངས།...",
"Added": "ཁ་སྣོན་བྱས་པའི་ཚེས་གྲངས།",
"GuanKiapTsingKhai-T": "ཡོན་ཞ་ཀྲིང་ཁའེ་-T",
"Description": "ངོ་སྤྲོད་བསྡུས་དོན།",
"No description available": "ད་རེས་ངོ་སྤྲོད་བསྡུས་དོན་མེད།",
"List": "ཐོ་རེའུ།",
"Grid": "དྲྭ་རྡལ།",
"(from 'As You Like It', Act II)": "《ཐམས་ཅད་དགའ་བ》ནས་དྲངས་པ། ལེའུ་གཉིས་པ།",
"Link Color": "སྦྲེལ་ཐག་གི་ཚོན་མདོག",
"Volume Keys for Page Flip": "སྒྲ་ཚད་ལྡེ་མིག་གིས་ཤོག་ལྷེ་བསྒྱུར་བ།",
"Clicks for Page Flip": "མནན་ནས་ཤོག་ལྷེ་བསྒྱུར་བ།",
"Swap Clicks Area": "མནན་སའི་ཁོངས་སུ་བརྗེ་བ།",
"Screen": "བརྡ་ཁྱབ་ངོས།",
"Orientation": "བརྡ་ཁྱབ་ངོས་ཀྱི་ཁ་ཕྱོགས།",
"Portrait": "ཀྲིང་ངོས།",
"Landscape": "འཕྲེད་ངོས།",
"Open Last Book on Start": "འགོ་སློང་སྐབས་སྔོན་མར་ཀློག་པའི་དཔེ་དེབ་ཁ་ཕྱེ་བ།",
"Checking for updates...": "བསྐྱར་བརྗེ་བརྟག་དཔྱད་བྱེད་བཞིན་པ།...",
"Error checking for updates": "བསྐྱར་བརྗེ་བརྟག་དཔྱད་བྱེད་སྐབས་ནོར་འཁྲུལ་བྱུང་།",
"Details": "ཞིབ་ཕྲ།",
"File Size": "ཡིག་ཆའི་ཆེ་ཆུང་།",
"Auto Detect": "རང་འགུལ་གྱིས་བརྟག་དཔྱད།",
"Next Section": "ལེའུ་རྗེས་མ།",
"Previous Section": "ལེའུ་སྔོན་མ།",
"Next Page": "ཤོག་ལྷེ་རྗེས་མ།",
"Previous Page": "ཤོག་ལྷེ་སྔོན་མ།",
"Are you sure to delete {{count}} selected book(s)?_other": "ཁྱེད་ཀྱིས་འདེམས་ཟིན་པའི་དཔེ་དེབ་ {{count}} བསུབ་དགོས་སམ།",
"Are you sure to delete the selected book?": "ཁྱེད་ཀྱིས་འདེམས་ཟིན་པའི་དཔེ་དེབ་བསུབ་དགོས་སམ།",
"Deselect": "འདེམས་པ་འདོར་བ།",
"Select All": "ཚང་མ་འདེམས་པ།",
"No translation available.": "ད་རེས་སྤྱོད་རྒྱུའི་སྒྱུར་བཅོས་མེད།",
"Translated by {{provider}}.": "{{provider}} ནས་སྒྱུར་བཅོས་བྱས་པ།",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Azure Translator": "Azure Translator",
"Invert Image In Dark Mode": "མདོག་ནག་པོའི་བརྗོད་བྱའི་འོག་ཏུ་པར་རིས་ཀྱི་ཚོན་མདོག་ལྡོག་པ།",
"Help improve Readest": "Readest ལེགས་བཅོས་ལ་རོགས་རམ་བྱེད་པ།",
"Sharing anonymized statistics": "མིང་མེད་ཀྱི་བསྡོམས་རྩིས་གཞི་གྲངས་མཉམ་སྤྱོད།",
"Interface Language": "མཐུད་ངོས་ཀྱི་སྐད་ཡིག",
"Translation": "སྒྱུར་བཅོས།",
"Enable Translation": "སྒྱུར་བཅོས་སྤྱོད་པ།",
"Translation Service": "སྒྱུར་བཅོས་ཞབས་ཞུ།",
"Translate To": "ནང་དུ་སྒྱུར་བ།",
"Disable Translation": "སྒྱུར་བཅོས་སྒོ་བརྒྱབ།",
"Scroll": "འཁོར་རྒྱུག",
"Overlap Pixels": "ལྷན་པའི་པིག་སེལ།",
"Click": "མནན་པ།",
"System Language": "མ་ལག་གི་སྐད་ཡིག",
"Security": "བདེ་འཇགས།",
"Allow JavaScript": "JavaScript ཆོག་མཆན།",
"Enable only if you trust the file.": "ཁྱེད་ཀྱིས་ཡིག་ཆ་འདིར་ཡིད་ཆེས་བྱེད་དུས་ཙམ་སྤྱོད་རྒྱུའི་གྲོས་འགལ།",
"Sort TOC by Page": "ཤོག་ཨང་ལྟར་དཀར་ཆག་རིམ་སྒྲིག་བྱེད་པ།",
"Search in {{count}} Book(s)..._other": "དཔེ་དེབ་ {{count}} ནང་འཚོལ་བཤེར་བྱེད་པ།...",
"No notes match your search": "མཐུན་པའི་ཟིན་བྲིས་མ་རྙེད།",
"Search notes and excerpts...": "ཟིན་བྲིས་འཚོལ་བཤེར།...",
"Sign in to Sync": "ནང་བསྐྱོད་བྱས་རྗེས་སྐད་མཉམ་བྱེད་པ།",
"Synced at {{time}}": "སྐད་མཉམ་བྱས་པའི་དུས་ཚོད། {{time}}",
"Never synced": "ནམ་ཡང་སྐད་མཉམ་བྱས་མ་མྱོང་།",
"Show Remaining Time": "ལྷག་མའི་དུས་ཚོད་མངོན་པ།",
"{{time}} min left in chapter": "ལེའུ་འདིར་ད་དུང་ {{time}} སྐར་མ་ལྷག་ཡོད།",
"Override Book Color": "དཔེ་དེབ་ཀྱི་ཚོན་མདོག་བརྗེ་བ།",
"Login Required": "ནང་བསྐྱོད་བྱ་དགོས།",
"Quota Exceeded": "ཚད་འཇལ་མི་འདང་།",
"{{percentage}}% of Daily Translation Characters Used.": "ཉིན་རེའི་སྒྱུར་བཅོས་ཀྱི་ཡི་གེའི་ {{percentage}}% སྤྱོད་ཟིན།",
"Translation Characters": "སྒྱུར་བཅོས་ཀྱི་ཡི་གེའི་གྲངས་ཀ།",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} སྒྲ་རྣམ་པ།",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "ཅུང་ཙམ་གྱི་གནད་དོན་བྱུང་བ་རེད། སེམས་ཁྲལ་མ་བྱེད། ང་ཚོའི་ཚོགས་པས་བརྡ་ཐོ་དང་ལེན་བྱས་ཟིན་པ་དང་ད་ལྟ་ཞིག་གི་ནང་བཅོས་བྱེད་བཞིན་པ་རེད།",
"Error Details:": "ནོར་འཁྲུལ་གྱི་ཞིབ་ཕྲ།",
"Try Again": "ཡང་བསྐྱར་ཚོད་ལྟ།",
"Need help?": "རོགས་རམ་དགོས་སམ།",
"Contact Support": "ཞབས་འདེགས་པར་འབྲེལ་བ།",
"Code Highlighting": "ཨང་རྟགས་གསལ་བཀྲོལ།",
"Enable Highlighting": "གསལ་བཀྲོལ་སྤྱོད་པ།",
"Code Language": "ཨང་རྟགས་ཀྱི་སྐད་ཡིག",
"Top Margin (px)": "སྟེང་ངོས་ཀྱི་བར་ཐག",
"Bottom Margin (px)": "འོག་ངོས་ཀྱི་བར་ཐག",
"Right Margin (px)": "གཡས་ངོས་ཀྱི་བར་ཐག",
"Left Margin (px)": "གཡོན་ངོས་ཀྱི་བར་ཐག",
"Column Gap (%)": "གྲལ་ཐིག་བར་གྱི་བར་ཐག",
"Always Show Status Bar": "རྟག་ཏུ་གནས་ཚུལ་གྱི་གྲལ་ཐིག་མངོན་པ།",
"Translation Not Available": "སྒྱུར་བཅོས་སྤྱོད་ཐུབ་མེད།",
"Custom Content CSS": "རང་འགུལ་གྱི་ནང་དོན་ CSS",
"Enter CSS for book content styling...": "དཔེ་དེབ་ཀྱི་ནང་དོན་གྱི་རྣམ་པའི་ CSS ནང་འཇུག་བྱོས།...",
"Custom Reader UI CSS": "རང་འགུལ་གྱི་མཐུད་ངོས་ CSS",
"Enter CSS for reader interface styling...": "ཀློག་ཆས་ཀྱི་མཐུད་ངོས་རྣམ་པའི་ CSS ནང་འཇུག་བྱོས།...",
"Crop": "བཏོག་པ།",
"Book Covers": "དཔེ་དེབ་ཀྱི་སྟོང་ངོས།",
"Fit": "མཐུན་སྒྲིག",
"Reset {{settings}}": "{{settings}} བསྐྱར་སྒྲིག",
"Reset Settings": "སྒྲིག་བཀོད་བསྐྱར་སྒྲིག",
"{{count}} pages left in chapter_other": "ལེའུ་འདིར་ད་དུང་ {{count}} ཤོག་ལྷེ་ལྷག་ཡོད།",
"Show Remaining Pages": "ལྷག་མའི་ཤོག་ལྷེའི་གྲངས་ཀ་མངོན་པ།",
"Source Han Serif CN VF": "སི་ཡོན་སུང་ཐི།",
"Huiwen-mincho": "ཧུའེ་ཝུན་མིང་ཁྲའོ་ཐི།",
"KingHwa_OldSong": "ཅིང་ཧྭ་ལའོ་སུང་ཐི།",
"Manage Subscription": "མཚམས་སྦྱོར་དོ་དམ།",
"Coming Soon": "ཉེ་བར་སྤྱོད་འགོ་འཛུགས་རྒྱུ།",
"Upgrade to {{plan}}": "{{plan}} ལ་རིམ་སྤོར་བྱེད་པ།",
"Upgrade to Plus or Pro": "Plus ཡང་ན་ Pro ལ་རིམ་སྤོར་བྱེད་པ།",
"Current Plan": "ད་ལྟའི་ཐུམ་སྒྲིལ།",
"Plan Limits": "ཐུམ་སྒྲིལ་གྱི་ཚད་བཀག",
"Processing your payment...": "དངུལ་སྤྲོད་པར་བརྟག་དཔྱད་བྱེད་བཞིན་པ།...",
"Please wait while we confirm your subscription.": "ཅུང་ཙམ་སྒུག་དང་། ང་ཚོས་ཁྱེད་ཀྱི་མཚམས་སྦྱོར་གཏན་འཁེལ་བྱེད་བཞིན་པ་རེད།",
"Payment Processing": "དངུལ་སྤྲོད་པར་བརྟག་དཔྱད་བྱེད་བཞིན་པ།",
"Your payment is being processed. This usually takes a few moments.": "ཁྱེད་ཀྱི་དངུལ་སྤྲོད་པར་བརྟག་དཔྱད་བྱེད་བཞིན་པ་ཡིན། སྤྱིར་ན་སྐར་ཆ་ཁ་ཤས་དགོས།",
"Payment Failed": "དངུལ་སྤྲོད་མ་ཐུབ།",
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "ང་ཚོས་ཁྱེད་ཀྱི་མཚམས་སྦྱོར་བརྟག་དཔྱད་བྱེད་ཐུབ་མེད། ཡང་བསྐྱར་ཚོད་ལྟ་བྱོས། ཡང་ན་གནད་དོན་རྒྱུན་མཐུད་བྱུང་ན་རྒྱབ་སྐྱོར་ལ་འབྲེལ་བ་གནང་རོགས།",
"Back to Profile": "མི་སྒེར་གྱི་ཆ་འཕྲིན་ལ་ཕྱིར་ལོག",
"Subscription Successful!": "མཚམས་སྦྱོར་བྱས་ཐུབ།",
"Thank you for your subscription! Your payment has been processed successfully.": "ཁྱེད་ཀྱི་མཚམས་སྦྱོར་ལ་ཐུགས་རྗེ་ཆེ། ཁྱེད་ཀྱི་དངུལ་སྤྲོད་ཐུབ་སོང་།",
"Email:": "གློག་རྡུལ་ཡིག་ཟམ།",
"Plan:": "ཐུམ་སྒྲིལ།",
"Amount:": "དངུལ་གྲངས།",
"Subscription ID:": "མཚམས་སྦྱོར་ ID །",
"Go to Library": "དཔེ་མཛོད་ལ་འགྲོ།",
"Need help? Contact our support team at support@readest.com": "རོགས་རམ་དགོས་སམ། རྒྱབ་སྐྱོར་ཚོགས་པར་འབྲེལ་བ་གནང་རོགས། support@readest.com",
"Free Plan": "རིན་མེད་ཐུམ་སྒྲིལ།",
"month": "ཟླ་བ།",
"AI Translations (per day)": "AI སྒྱུར་བཅོས།(ཉིན་རེར།)",
"Plus Plan": "Plus ཐུམ་སྒྲིལ།",
"Includes All Free Plan Benefits": "རིན་མེད་ཐུམ་སྒྲིལ་གྱི་བྱེད་ནུས་ཚང་མ་ཚུད་ཡོད།",
"Pro Plan": "Pro ཐུམ་སྒྲིལ།",
"More AI Translations": "AI སྒྱུར་བཅོས་དེ་བས་མང་བ།",
"Complete Your Subscription": "ཁྱེད་ཀྱི་མཚམས་སྦྱོར་ལེགས་གྲུབ།",
"{{percentage}}% of Cloud Sync Space Used.": "སྤྲི་དོན་སྐད་མཉམ་གྱི་བར་སྟོང་གི་ {{percentage}}% སྤྱོད་ཟིན།",
"Cloud Sync Storage": "སྤྲི་དོན་སྐད་མཉམ་གྱི་བར་སྟོང་།",
"Disable": "སྒོ་བརྒྱབ།",
"Enable": "སྤྱོད་པ།",
"Upgrade to Readest Premium": "Readest Premium ལ་རིམ་སྤོར་བྱེད་པ།",
"Show Source Text": "མགོ་ཡིག་མངོན་པ།",
"Cross-Platform Sync": "ལས་སྟེགས་བརྒལ་བའི་སྐད་མཉམ།",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "སྒྲིག་ཆས་ཡོད་རྒུའི་བར་དུ་ཁྱེད་ཀྱི་དཔེ་མཛོད། ཡར་འཕེལ། མཚན་སྟོན་དང་ཟིན་བྲིས་བཅས་སྐད་མཉམ་བྱེད་པ།——ཀློག་པའི་གནས་ས་ནམ་ཡང་བོར་མི་སྲིད།",
"Customizable Reading": "མི་སྒེར་གྱི་ཀློག་ཚུལ།",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "འགྱུར་བཅོས་བྱེད་ཐུབ་པའི་ཡིག་གཟུགས། བཀོད་སྒྲིག བརྗོད་བྱ་དང་མཐོ་རིམ་གྱི་མངོན་པའི་སྒྲིག་བཀོད་བཅས་བརྒྱུད་ནས་ཞིབ་ཕྲ་རེ་རེ་མི་སྒེར་གྱི་རང་བཞིན་ལྡན་པར་བྱས་ཏེ། ངོ་མཚར་ཆེ་བའི་ཀློག་ཚུལ་ཞིག་ཐོབ།",
"AI Read Aloud": "AI སྒྲ་ཀློག",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "ལག་པ་གཉིས་ཐར་བའི་ཀློག་ཚུལ་ལོངས་སུ་སྤྱོད། རང་བཞིན་གྱི་ AI སྒྲ་སྐད་བེད་སྤྱད་ནས་དཔེ་དེབ་སླར་གསོ་བྱེད།",
"AI Translations": "AI སྒྱུར་བཅོས།",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Google དང་ Azure ཡང་ན་ DeepL བཅས་ཀྱི་ནུས་པ་ཆེ་བའི་བྱེད་ནུས་བརྒྱུད་ནས་སྐད་ཡིག་གང་རུང་གི་ནང་དོན་དུས་ཐོག་ཏུ་སྒྱུར་བཅོས་བྱེད།",
"Connect with fellow readers and get help fast in our friendly community channels.": "ཀློག་པ་པ་གཞན་དང་མཉམ་འབྲེལ་བྱས་ཏེ། ང་ཚོའི་མཛའ་བརྩེའི་སྡེ་ཁུལ་གྱི་ཐོག་མཐུད་ནང་མྱུར་དུ་རོགས་རམ་ཐོབ།",
"Unlimited AI Read Aloud Hours": "AI སྒྲ་ཀློག་གི་དུས་ཚོད་ཚད་མེད།",
"Listen without limits—convert as much text as you like into immersive audio.": "ཡི་གེ་གང་འདོད་སྒྲ་སྐད་དུ་བསྒྱུར་བ།",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "སྒྱུར་བཅོས་ཀྱི་བྱེད་ནུས་ཇེ་མཐོར་གཏོང་བ། སྒྱུར་བཅོས་ཀྱི་ཚད་འཇལ་དེ་བས་མང་བ། མཐོ་རིམ་གྱི་འདེམས་གཞི།",
"DeepL Pro Access": "DeepL Pro ཀྱི་སྤྱོད་དབང་།",
"Enjoy faster responses and dedicated assistance whenever you need help.": "དུས་ཐོག་ཏུ་ལན་འདེབས་མགྱོགས་པོ་དང་ཆེད་སྤྱོད་ཀྱི་རོགས་རམ་ལོངས་སུ་སྤྱོད།",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "ཉིན་རེའི་སྒྱུར་བཅོས་ཀྱི་ཚད་འཇལ་ལ་སླེབས་འདུག ཐུམ་སྒྲིལ་རིམ་སྤོར་བྱས་ནས་ AI སྒྱུར་བཅོས་མུ་མཐུད་དུ་སྤྱོད།",
"Includes All Plus Plan Benefits": "Plus ཐུམ་སྒྲིལ་གྱི་བྱེད་ནུས་ཚང་མ་ཚུད་ཡོད།",
"Early Feature Access": "བྱེད་ནུས་གསར་པ་སྔོན་ལ་ཉམས་སུ་མྱོང་བ།",
"Be the first to explore new features, updates, and innovations before anyone else.": "བྱེད་ནུས་གསར་པ། བསྐྱར་བརྗེ་དང་གསར་གཏོད་བཅས་སྔོན་ལ་ཉམས་སུ་མྱོང་བ།",
"Advanced AI Tools": "མཐོ་རིམ་གྱི་ AI ལག་ཆ།",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "ནུས་པ་ཆེ་བའི་ AI ལག་ཆ་བེད་སྤྱད་ནས་ཤེས་ལྡན་གྱི་ཀློག་ཚུལ། སྒྱུར་བཅོས་དང་ནང་དོན་རྟོགས་པ་བཅས་མངོན་འགྱུར་བྱེད།",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "ཆེས་གནད་ལ་ཁེལ་བའི་སྒྱུར་བཅོས་འཕྲུལ་ཆས་བེད་སྤྱད་ནས་ཉིན་རེར་ཡིག་འབྲུ་ཁྲི་ཚོ 10 སྒྱུར་བཅོས་བྱེད་ཐུབ།",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "ཆེས་གནད་ལ་ཁེལ་བའི་སྒྱུར་བཅོས་འཕྲུལ་ཆས་བེད་སྤྱད་ནས་ཉིན་རེར་ཡིག་འབྲུ་ཁྲི་ཚོ 50 སྒྱུར་བཅོས་བྱེད་ཐུབ།",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "བདེ་འཇགས་ཀྱི་སྤྲི་དོན་ཉར་ཚགས་ 5GB བར་ཁྱབ་ཏུ་བེད་སྤྱད་ནས་ཁྱེད་ཀྱི་ཀློག་ཚུལ་ཡོད་རྒུ་ཉར་ཚགས་དང་སྤྱོད་པ།",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "བདེ་འཇགས་ཀྱི་སྤྲི་དོན་ཉར་ཚགས་ 20GB བར་ཁྱབ་ཏུ་བེད་སྤྱད་ནས་ཁྱེད་ཀྱི་ཀློག་ཚུལ་ཡོད་རྒུ་ཉར་ཚགས་དང་སྤྱོད་པ།",
"Deleted cloud backup of the book: {{title}}": "དཔེ་དེབ་ཀྱི་སྤྲི་དོན་གྱི་གསོག་ཉར་བསུབ་ཟིན། {{title}}",
"Are you sure to delete the cloud backup of the selected book?": "ཁྱེད་ཀྱིས་འདེམས་ཟིན་པའི་དཔེ་དེབ་ཀྱི་སྤྲི་དོན་གྱི་གསོག་ཉར་བསུབ་དགོས་སམ།",
"What's New in Readest": "Readest ཀྱི་བྱེད་ནུས་གསར་པ།",
"Enter book title": "དཔེ་དེབ་ཀྱི་མིང་ནང་འཇུག་བྱོས།",
"Subtitle": "ལེའུ་གཉིས་པའི་མགོ་བྱང་།",
"Enter book subtitle": "དཔེ་དེབ་ཀྱི་ལེའུ་གཉིས་པའི་མགོ་བྱང་ནང་འཇུག་བྱོས།",
"Enter author name": "རྩོམ་པ་པོའི་མིང་ནང་འཇུག་བྱོས།",
"Series": "རབས་རིམ།",
"Enter series name": "རབས་རིམ་གྱི་མིང་ནང་འཇུག་བྱོས།",
"Series Index": "རབས་རིམ་གྱི་ཨང་གྲངས།",
"Enter series index": "རབས་རིམ་གྱི་ཨང་གྲངས་ནང་འཇུག་བྱོས།",
"Total in Series": "རབས་རིམ་གྱི་བསྡོམས་གྲངས།",
"Enter total books in series": "རབས་རིམ་ནང་གི་དཔེ་དེབ་བསྡོམས་གྲངས་ནང་འཇུག་བྱོས།",
"Enter publisher": "དཔེ་སྐྲུན་ཁང་ནང་འཇུག་བྱོས།",
"Publication Date": "དཔེ་སྐྲུན་བྱས་པའི་ཚེས་གྲངས།",
"Identifier": "ངོས་འཛིན་རྟགས།",
"Enter book description": "དཔེ་དེབ་ཀྱི་ངོ་སྤྲོད་ནང་འཇུག་བྱོས།",
"Change cover image": "སྟོང་ངོས་ཀྱི་པར་རིས་བརྗེ་བ།",
"Replace": "བརྗེ་བ།",
"Unlock cover": "སྟོང་ངོས་སྒོ་ཕྱེ་བ།",
"Lock cover": "སྟོང་ངོས་བཀག་པ།",
"Auto-Retrieve Metadata": "རང་འགུལ་གྱིས་གཞི་གྲངས་ལེན་པ།",
"Auto-Retrieve": "རང་འགུལ་གྱིས་ལེན་པ།",
"Unlock all fields": "ཡིག་སྣེ་ཚང་མ་སྒོ་ཕྱེ་བ།",
"Unlock All": "ཚང་མ་སྒོ་ཕྱེ་བ།",
"Lock all fields": "ཡིག་སྣེ་ཚང་མ་བཀག་པ།",
"Lock All": "ཚང་མ་བཀག་པ།",
"Reset": "བསྐྱར་སྒྲིག",
"Edit Metadata": "གཞི་གྲངས་རྩོམ་སྒྲིག",
"Locked": "བཀག་ཟིན།",
"Select Metadata Source": "གཞི་གྲངས་ཀྱི་འབྱུང་ཁུངས་འདེམས་པ།",
"Keep manual input": "ལག་བཟོས་ནང་འཇུག་བྱས་པ་ཉར་བ།",
"Google Books": "Google དཔེ་མཛོད།",
"Open Library": "སྒོ་འབྱེད་དཔེ་ཁང་།",
"Fiction, Science, History": "སྒྲུང་གཏམ། ཚན་རིག ལོ་རྒྱུས།",
"Select Cover Image": "སྟོང་ངོས་ཀྱི་པར་རིས་འདེམས་པ།",
"Open Book in New Window": "སྒེའུ་ཁུང་གསར་པའི་ནང་དཔེ་དེབ་ཁ་ཕྱེ་བ།",
"Voices for {{lang}}": "{{lang}} གྱི་སྒྲ་སྐད།",
"Yandex Translate": "Yandex སྒྱུར་བཅོས།",
"YYYY or YYYY-MM-DD": "YYYY ཡང་ན་ YYYY-MM-DD",
"Restore Purchase": "ཉོ་སྒྲུབ་སླར་གསོ།",
"No purchases found to restore.": "སླར་གསོ་བྱ་རྒྱུའི་ཉོ་སྒྲུབ་མ་རྙེད།",
"Failed to restore purchases.": "ཉོ་སྒྲུབ་སླར་གསོ་བྱས་མ་ཐུབ།",
"Failed to manage subscription.": "མཚམས་སྦྱོར་དོ་དམ་བྱས་མ་ཐུབ།",
"Failed to load subscription plans.": "མཚམས་སྦྱོར་འཆར་གཞི་འགུལ་སྐྱོང་བྱས་མ་ཐུབ།",
"year": "ལོ།",
"Failed to create checkout session": "དངུལ་སྤྲོད་ཚོགས་འདུ་བཟོ་ཐུབ་མེད།",
"Storage": "སྤྲི་དོན་གྱི་བར་སྟོང་།",
"Terms of Service": "ཞབས་འདེགས་ཀྱི་ཆ་རྐྱེན།",
"Privacy Policy": "སྒེར་གསང་གི་སྲིད་ཇུས།",
"Disable Double Click": "ཐེངས་གཉིས་མནན་པ་སྒོ་བརྒྱབ།",
"TTS not supported for this document": "ཡིག་ཆ་འདིར་ TTS རྒྱབ་སྐྱོར་མི་བྱེད།",
"Reset Password": "གསང་ཨང་བསྐྱར་སྒྲིག",
"Show Reading Progress": "ཀློག་པའི་ཡར་འཕེལ་མངོན་པ།",
"Reading Progress Style": "ཀློག་པའི་ཡར་འཕེལ་གྱི་རྣམ་པ།",
"Page Number": "ཤོག་ཨང་།",
"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": "སྒྲིག་ཆས་ཐོག་ནས་ཕྱིར་འཐེན།",
"Failed to connect": "འབྲེལ་བ་བྱས་མ་ཐུབ།",
"Disconnected": "འབྲེལ་བ་བྱས་མ་ཐུབ།",
"KOReader Sync Settings": "KOReader སྤྲི་དོན་གཞི་འདེམས་པ།",
"Sync as {{userDisplayName}}": "ཁྱེད་ཀྱིས {{userDisplayName}} སྤྲི་དོན་གཞི་འདེམས་པ།",
"Sync Server Connected": "སྤྲི་དོན་སང་བཞིན་འདེམས་པ།",
"Sync Strategy": "སྤྲི་དོན་འབྱུང་ཁུངས།",
"Ask on conflict": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Always use latest": "དེ་ལས་འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Send changes only": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Receive changes only": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Disabled": "བཀག་པ།",
"Checksum Method": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"File Content (recommended)": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"File Name": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Device Name": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Sync Tolerance": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Precision: {{precision}} digits after the decimal": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Connect to your KOReader Sync server.": "ཁྱེད་ཀྱིས KOReader སྤྲི་དོན་སང་བཞིན་འདེམས་པ།",
"Server URL": "སང་བཞིན་འདེམས་པའི URL",
"Username": "ཁྱེད་ཀྱིས་འབྱུང་ཁུངས་འདེམས་པ།",
"Your Username": "ཁྱེད་ཀྱིས་འབྱུང་ཁུངས་འདེམས་པ།",
"Password": "གསང་ཨང་",
"Connect": "འབྲེལ་བ་བྱས་པ།",
"KOReader Sync": "KOReader སྤྲི་དོན་འདེམས་པ།",
"Sync Conflict": "སྤྲི་དོན་འབྱུང་ཁུངས།",
"Sync reading progress from \"{{deviceName}}\"?": "ཁྱེད་ཀྱིས \"{{deviceName}}\" སྤྲི་དོན་འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"another device": "གཞན་ཡིག་སྣེ།",
"Local Progress": "ཀུན་འབྱུང་ཁུངས།",
"Remote Progress": "བརྒྱབ་འབྱུང་ཁུངས།",
"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}}%": "ད་ལྟ་བཞིན་འདེམས་པ།"
}
@@ -94,7 +94,6 @@
"Speak": "Sprechen",
"Subjects": "Themen",
"System Fonts": "System-Schriftarten",
"TTS not supported in this device": "TTS wird auf diesem Gerät nicht unterstützt",
"Theme Color": "Themenfarbe",
"Theme Mode": "Themenmodus",
"Translate": "Übersetzen",
@@ -311,7 +310,6 @@
"Synced at {{time}}": "Synchronisiert um {{time}}",
"Never synced": "Nie synchronisiert",
"Show Remaining Time": "Verbleibende Zeit anzeigen",
"Show Page Number": "Seitenzahl anzeigen",
"{{time}} min left in chapter": "{{time}} min verbleibend im Kapitel",
"Override Book Color": "Farbe des Buches überschreiben",
"Login Required": "Anmeldung nötig",
@@ -411,7 +409,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 5 GB Cloud-Speicher zu.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 20 GB Cloud-Speicher zu.",
"Deleted cloud backup of the book: {{title}}": "Gelöschte Cloud-Sicherung des Buches: {{title}}",
"Failed to delete cloud backup of the book": "Fehler beim Löschen der Cloud-Sicherung des Buches",
"Are you sure to delete the cloud backup of the selected book?": "Sind Sie sicher, dass Sie die Cloud-Sicherung des ausgewählten Buches löschen möchten?",
"What's New in Readest": "Was gibt es Neues in Readest",
"Enter book title": "Buchtitel eingeben",
@@ -461,5 +458,51 @@
"Storage": "Speicher",
"Terms of Service": "Allgemeine Geschäftsbedingungen",
"Privacy Policy": "Datenschutzbestimmungen",
"Disable Double Click": "Doppelklick deaktivieren"
"Disable Double Click": "Doppelklick deaktivieren",
"TTS not supported for this document": "TTS wird für dieses Dokument nicht unterstützt",
"Reset Password": "Passwort zurücksetzen",
"Show Reading Progress": "Lesefortschritt anzeigen",
"Reading Progress Style": "Lesefortschritt-Stil",
"Page Number": "Seitenzahl",
"Percentage": "Prozentsatz",
"Deleted local copy of the book: {{title}}": "Gelöschte lokale Kopie des Buches: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Fehler beim Löschen der Cloud-Sicherung des Buches: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Fehler beim Löschen der lokalen Kopie des Buches: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Sind Sie sicher, dass Sie die lokale Kopie des ausgewählten Buches löschen möchten?",
"Remove from Cloud & Device": "Aus Cloud & Gerät entfernen",
"Remove from Cloud Only": "Nur aus der Cloud entfernen",
"Remove from Device Only": "Nur vom Gerät entfernen",
"Disconnected": "Getrennt",
"KOReader Sync Settings": "KOReader Sync-Einstellungen",
"Sync Strategy": "Sync-Strategie",
"Ask on conflict": "Bei Konflikten fragen",
"Always use latest": "Immer die neueste Version verwenden",
"Send changes only": "Nur Änderungen senden",
"Receive changes only": "Nur Änderungen empfangen",
"Disabled": "Deaktiviert",
"Checksum Method": "Prüfziffernverfahren",
"File Content (recommended)": "Dateiinhalte (empfohlen)",
"File Name": "Dateiname",
"Device Name": "Gerätename",
"Sync Tolerance": "Sync-Toleranz",
"Connect to your KOReader Sync server.": "Mit Ihrem KOReader Sync-Server verbinden.",
"Server URL": "Server-URL",
"Username": "Benutzername",
"Your Username": "Ihr Benutzername",
"Password": "Passwort",
"Connect": "Verbinden",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Sync-Konflikt",
"Sync reading progress from \"{{deviceName}}\"?": "Lesefortschritt von \"{{deviceName}}\" synchronisieren?",
"another device": "ein anderes Gerät",
"Local Progress": "Lokaler Fortschritt",
"Remote Progress": "Entfernter Fortschritt",
"Page {{page}} of {{total}} ({{percentage}}%)": "Seite {{page}} von {{total}} ({{percentage}}%)",
"Current position": "Aktuelle Position",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Ungefähr Seite {{page}} von {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Ungefähr {{percentage}}%",
"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}}"
}
@@ -95,7 +95,6 @@
"Speak": "Ομιλία",
"Subjects": "Θέματα",
"System Fonts": "Γραμματοσειρές συστήματος",
"TTS not supported in this device": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται σε αυτή τη συσκευή",
"Theme Color": "Χρώμα θέματος",
"Theme Mode": "Λειτουργία θέματος",
"Translate": "Μετάφραση",
@@ -312,7 +311,6 @@
"Synced at {{time}}": "Συγχρονίστηκε στις {{time}}",
"Never synced": "Ποτέ δεν συγχρονίστηκε",
"Show Remaining Time": "Εμφάνιση υπολειπόμενου χρόνου",
"Show Page Number": "Εμφάνιση αριθμού σελίδας",
"{{time}} min left in chapter": "{{time}} λεπτά απομένουν στο κεφάλαιο",
"Override Book Color": "Παράκαμψη χρώματος βιβλίου",
"Login Required": "Απαιτείται σύνδεση",
@@ -411,7 +409,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 5 GB ασφαλή cloud αποθήκευση.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 20 GB ασφαλή cloud αποθήκευση.",
"Deleted cloud backup of the book: {{title}}": "Διαγράφηκε το αντίγραφο ασφαλείας του βιβλίου: {{title}}",
"Failed to delete cloud backup of the book": "Αποτυχία διαγραφής του αντιγράφου ασφαλείας του βιβλίου",
"Are you sure to delete the cloud backup of the selected book?": "Είστε σίγουροι ότι θέλετε να διαγράψετε το αντίγραφο ασφαλείας του επιλεγμένου βιβλίου;",
"What's New in Readest": "Τι νέο υπάρχει στο Readest",
"Enter book title": "Εισάγετε τίτλο βιβλίου",
@@ -461,5 +458,51 @@
"Storage": "Αποθήκευση",
"Terms of Service": "Όροι χρήσης",
"Privacy Policy": "Πολιτική απορρήτου",
"Disable Double Click": "Απενεργοποίηση διπλού κλικ"
"Disable Double Click": "Απενεργοποίηση διπλού κλικ",
"TTS not supported for this document": "Η τεχνολογία TTS δεν υποστηρίζεται για αυτό το έγγραφο.",
"Reset Password": "Επαναφορά κωδικού",
"Show Reading Progress": "Εμφάνιση προόδου ανάγνωσης",
"Reading Progress Style": "Στυλ προόδου ανάγνωσης",
"Page Number": "Αριθμός σελίδας",
"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": "Αφαίρεση από το Cloud & Device",
"Remove from Cloud Only": "Αφαίρεση μόνο από το Cloud",
"Remove from Device Only": "Αφαίρεση μόνο από το Device",
"Disconnected": "Αποσυνδεδεμένο",
"KOReader Sync Settings": "Ρυθμίσεις συγχρονισμού KOReader",
"Sync Strategy": "Στρατηγική συγχρονισμού",
"Ask on conflict": "Ρώτησε σε περίπτωση σύγκρουσης",
"Always use latest": "Χρησιμοποίησε πάντα την τελευταία έκδοση",
"Send changes only": "Αποστολή μόνο αλλαγών",
"Receive changes only": "Λήψη μόνο αλλαγών",
"Disabled": "Απενεργοποιημένο",
"Checksum Method": "Μέθοδος ελέγχου",
"File Content (recommended)": "Περιεχόμενο αρχείου (συνιστάται)",
"File Name": "Όνομα αρχείου",
"Device Name": "Όνομα συσκευής",
"Sync Tolerance": "Ανοχή συγχρονισμού",
"Connect to your KOReader Sync server.": "Συνδεθείτε στον διακομιστή συγχρονισμού KOReader.",
"Server URL": "Διεύθυνση URL διακομιστή",
"Username": "Όνομα χρήστη",
"Your Username": "Το όνομα χρήστη σας",
"Password": "Κωδικός πρόσβασης",
"Connect": "Σύνδεση",
"KOReader Sync": "Συγχρονισμός KOReader",
"Sync Conflict": "Σύγκρουση συγχρονισμού",
"Sync reading progress from \"{{deviceName}}\"?": "Συγχρονίστε την πρόοδο ανάγνωσης από το \"{{deviceName}}\";",
"another device": "άλλη συσκευή",
"Local Progress": "Τοπική πρόοδος",
"Remote Progress": "Απομακρυσμένη πρόοδος",
"Page {{page}} of {{total}} ({{percentage}}%)": "Σελίδα {{page}} από {{total}} ({{percentage}}%)",
"Current position": "Τρέχουσα θέση",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Περίπου σελίδα {{page}} από {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Περίπου {{percentage}}%",
"Failed to connect": "Αποτυχία σύνδεσης",
"Sync Server Connected": "Ο διακομιστής συγχρονισμού είναι συνδεδεμένος",
"Precision: {{precision}} digits after the decimal": "Ακρίβεια: {{precision}} ψηφία μετά την υποδιαστολή",
"Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}"
}
@@ -69,7 +69,36 @@
"Parallel Read": "Lectura paralela",
"Published": "Publicado",
"Publisher": "Editorial",
"KOReader Sync": "Sincronización con KOReader",
"KOReader Sync Settings": "Ajustes de Sincronización con KOReader",
"Your Username": "Tu nombre de usuario",
"Connect to your KOReader Sync server.": "Conéctate a tu servidor de KOReader Sync.",
"Server URL": "URL del Servidor",
"Username": "Usuario",
"Password": "Contraseña",
"Connect": "Conectar",
"Disconnected": "Desconectado",
"Sync Strategy": "Estrategia de sincronización",
"Ask on conflict": "Preguntar en caso de conflicto",
"Always use latest": "Usar siempre el más reciente",
"Send changes only": "Solo enviar cambios",
"Receive changes only": "Solo recibir cambios",
"Disabled": "Desactivado",
"Checksum Method": "Método de identificación",
"File Content (recommended)": "Contenido del archivo (recomendado)",
"File Name": "Nombre del archivo",
"Device Name": "Nombre del dispositivo",
"Sync Tolerance": "Tolerancia de sincronización",
"Reading Progress Synced": "Progreso de lectura sincronizado",
"Sync Conflict": "Conflicto de sincronización",
"Sync reading progress from \"{{deviceName}}\"?": "¿Quieres sincronizar el progreso de lectura desde el dispositivo \"{{deviceName}}\"?",
"Local Progress": "Progreso local",
"Remote Progress": "Progreso remoto",
"Page {{page}} of {{total}} ({{percentage}}%)": "Página {{page}} de {{total}} ({{percentage}}%)",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Aproximadamente página {{page}} de {{total}} ({{percentage}}%)",
"Current position": "Posición actual",
"Approximately {{percentage}}%": "Aproximadamente {{percentage}}%",
"another device": "otro dispositivo",
"Reload Page": "Recargar página",
"Reveal in File Explorer": "Mostrar en el Explorador de archivos",
"Reveal in Finder": "Mostrar en Finder",
@@ -95,7 +124,6 @@
"Speak": "Leer",
"Subjects": "Temas",
"System Fonts": "Fuentes del sistema",
"TTS not supported in this device": "TTS no es compatible con este dispositivo",
"Theme Color": "Color del tema",
"Theme Mode": "Modo del tema",
"Translate": "Traducir",
@@ -314,7 +342,6 @@
"Synced at {{time}}": "Sincronizado a las {{time}}",
"Never synced": "Nunca sincronizado",
"Show Remaining Time": "Mostrar tiempo restante",
"Show Page Number": "Mostrar número de página",
"{{time}} min left in chapter": "{{time}} min restantes en el capítulo",
"Override Book Color": "Sobre escribir color del libro",
"Login Required": "Iniciar sesión",
@@ -415,7 +442,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 5 GB de almacenamiento en la nube.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 20 GB de almacenamiento en la nube.",
"Deleted cloud backup of the book: {{title}}": "Respaldo en la nube del libro eliminado: {{title}}",
"Failed to delete cloud backup of the book": "Fallo al eliminar el respaldo en la nube del libro",
"Are you sure to delete the cloud backup of the selected book?": "¿Estás seguro de que deseas eliminar el respaldo en la nube del libro seleccionado?",
"What's New in Readest": "¿Qué hay de nuevo en Readest",
"Enter book title": "Ingresa el título del libro",
@@ -465,5 +491,22 @@
"Storage": "Almacenamiento",
"Terms of Service": "Condiciones del servicio",
"Privacy Policy": "Política de privacidad",
"Disable Double Click": "Deshabilitar Doble Clic"
"Disable Double Click": "Deshabilitar Doble Clic",
"TTS not supported for this document": "TTS no es compatible con este documento.",
"Reset Password": "Restablecer contraseña",
"Reading Progress Style": "Estilo de progreso de lectura",
"Percentage": "Porcentaje",
"Show Reading Progress": "Mostrar progreso de lectura",
"Page Number": "Número de página",
"Deleted local copy of the book: {{title}}": "Se eliminó la copia local del libro: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Error al eliminar la copia de seguridad en la nube del libro: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Error al eliminar la copia local del libro: {{title}}",
"Are you sure to delete the local copy of the selected book?": "¿Está seguro de que desea eliminar la copia local del libro seleccionado?",
"Remove from Cloud & Device": "Eliminar de la nube y del dispositivo",
"Remove from Cloud Only": "Eliminar solo de la nube",
"Remove from Device Only": "Eliminar solo del dispositivo",
"Failed to connect": "Error al conectar",
"Sync Server Connected": "Servidor de sincronización conectado",
"Precision: {{precision}} digits after the decimal": "Precisión: {{precision}} dígitos después del decimal",
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}"
}
@@ -95,7 +95,6 @@
"Speak": "Lire",
"Subjects": "Sujets",
"System Fonts": "Polices système",
"TTS not supported in this device": "La synthèse vocale n'est pas prise en charge sur cet appareil",
"Theme Color": "Couleur du thème",
"Theme Mode": "Mode du thème",
"Translate": "Traduire",
@@ -313,7 +312,6 @@
"Synced at {{time}}": "Synchronisé à {{time}}",
"Never synced": "Jamais synchronisé",
"Show Remaining Time": "Afficher le temps restant",
"Show Page Number": "Afficher le numéro de page",
"{{time}} min left in chapter": "{{time}} min restant dans le chapitre",
"Override Book Color": "Remplacer la couleur du livre",
"Login Required": "Connexion requise",
@@ -414,7 +412,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 5 GB de stockage cloud.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 20 GB de stockage cloud.",
"Deleted cloud backup of the book: {{title}}": "Suppression de la sauvegarde cloud du livre : {{title}}",
"Failed to delete cloud backup of the book": "Échec de la suppression de la sauvegarde cloud du livre",
"Are you sure to delete the cloud backup of the selected book?": "Êtes-vous sûr de vouloir supprimer la sauvegarde cloud du livre sélectionné ?",
"Don't have an account? Sign up": "Vous n'avez pas de compte ? Inscrivez-vous",
"What's New in Readest": "Quoi de neuf dans Readest",
@@ -465,5 +462,51 @@
"Storage": "Stockage",
"Terms of Service": "Conditions d'utilisation",
"Privacy Policy": "Politique de confidentialité",
"Disable Double Click": "Désactiver le double-clic"
"Disable Double Click": "Désactiver le double-clic",
"TTS not supported for this document": "TTS non pris en charge pour ce document",
"Reset Password": "Nouveau mot de passe",
"Show Reading Progress": "Afficher la progression",
"Reading Progress Style": "Style de progression",
"Page Number": "Numéro de page",
"Percentage": "Pourcentage",
"Deleted local copy of the book: {{title}}": "Suppression de la copie locale du livre : {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Échec de la suppression de la sauvegarde cloud du livre : {{title}}",
"Failed to delete local copy of the book: {{title}}": "Échec de la suppression de la copie locale du livre : {{title}}",
"Are you sure to delete the local copy of the selected book?": "Êtes-vous sûr de vouloir supprimer la copie locale du livre sélectionné ?",
"Remove from Cloud & Device": "Supprimer du Cloud & de l'appareil",
"Remove from Cloud Only": "Supprimer uniquement du Cloud",
"Remove from Device Only": "Supprimer uniquement de l'appareil",
"Disconnected": "Déconnecté",
"KOReader Sync Settings": "Paramètres de synchronisation KOReader",
"Sync Strategy": "Stratégie de synchronisation",
"Ask on conflict": "Demander en cas de conflit",
"Always use latest": "Toujours utiliser la dernière version",
"Send changes only": "Envoyer uniquement les modifications",
"Receive changes only": "Recevoir uniquement les modifications",
"Disabled": "Désactivé",
"Checksum Method": "Méthode de somme de contrôle",
"File Content (recommended)": "Contenu du fichier (recommandé)",
"File Name": "Nom du fichier",
"Device Name": "Nom de l'appareil",
"Sync Tolerance": "Tolérance de synchronisation",
"Connect to your KOReader Sync server.": "Connectez-vous à votre serveur de synchronisation KOReader.",
"Server URL": "URL du serveur",
"Username": "Nom d'utilisateur",
"Your Username": "Votre nom d'utilisateur",
"Password": "Mot de passe",
"Connect": "Connecter",
"KOReader Sync": "Synchronisation KOReader",
"Sync Conflict": "Conflit de synchronisation",
"Sync reading progress from \"{{deviceName}}\"?": "Synchroniser la progression de lecture depuis \"{{deviceName}}\"?",
"another device": "un autre appareil",
"Local Progress": "Progression locale",
"Remote Progress": "Progression distante",
"Page {{page}} of {{total}} ({{percentage}}%)": "Page {{page}} sur {{total}} ({{percentage}}%)",
"Current position": "Position actuelle",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Environ page {{page}} sur {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Environ {{percentage}}%",
"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}}"
}
@@ -95,7 +95,6 @@
"Speak": "बोलें",
"Subjects": "विषय",
"System Fonts": "सिस्टम फ़ॉन्ट्स",
"TTS not supported in this device": "यह डिवाइस TTS का समर्थन नहीं करता",
"Theme Color": "थीम रंग",
"Theme Mode": "थीम मोड",
"Translate": "अनुवाद करें",
@@ -312,7 +311,6 @@
"Synced at {{time}}": "{{time}} पर सिंक किया गया",
"Never synced": "कभी सिंक नहीं किया गया",
"Show Remaining Time": "शेष समय दिखाएं",
"Show Page Number": "पृष्ठ संख्या दिखाएं",
"{{time}} min left in chapter": "{{time}} मिनट शेष अध्याय में",
"Override Book Color": "पुस्तक रंग ओवरराइड करें",
"Login Required": "लॉगिन ज़रूरी",
@@ -411,7 +409,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "5 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "20 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
"Deleted cloud backup of the book: {{title}}": "पुस्तक का क्लाउड बैकअप हटाया गया: {{title}}",
"Failed to delete cloud backup of the book": "पुस्तक का क्लाउड बैकअप हटाने में विफल",
"Are you sure to delete the cloud backup of the selected book?": "क्या आप सुनिश्चित हैं कि आप चयनित पुस्तक का क्लाउड बैकअप हटाना चाहते हैं?",
"What's New in Readest": "Readest में नया क्या है",
"Enter book title": "पुस्तक का शीर्षक दर्ज करें",
@@ -461,5 +458,51 @@
"Storage": "भंडारण",
"Terms of Service": "सेवा की शर्तें",
"Privacy Policy": "गोपनीयता नीति",
"Disable Double Click": "डबल क्लिक अक्षम करें"
"Disable Double Click": "डबल क्लिक अक्षम करें",
"TTS not supported for this document": "TTS इस दस्तावेज़ के लिए समर्थित नहीं है",
"Reset Password": "पासवर्ड रीसेट करें",
"Show Reading Progress": "पढ़ने की प्रगति दिखाएं",
"Reading Progress Style": "पढ़ने की प्रगति शैली",
"Page Number": "पृष्ठ संख्या",
"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": "अविचलित",
"KOReader Sync Settings": "KOReader सिंक सेटिंग्स",
"Sync Strategy": "सिंक रणनीति",
"Ask on conflict": "संघर्ष पर पूछें",
"Always use latest": "हमेशा नवीनतम का उपयोग करें",
"Send changes only": "केवल परिवर्तन भेजें",
"Receive changes only": "केवल परिवर्तन प्राप्त करें",
"Disabled": "अक्षम",
"Checksum Method": "चेकसम विधि",
"File Content (recommended)": "फाइल सामग्री (अनुशंसित)",
"File Name": "फाइल नाम",
"Device Name": "डिवाइस नाम",
"Sync Tolerance": "सिंक सहिष्णुता",
"Connect to your KOReader Sync server.": "अपने KOReader सिंक सर्वर से कनेक्ट करें।",
"Server URL": "सर्वर यूआरएल",
"Username": "उपयोगकर्ता नाम",
"Your Username": "आपका उपयोगकर्ता नाम",
"Password": "पासवर्ड",
"Connect": "कनेक्ट करें",
"KOReader Sync": "KOReader सिंक",
"Sync Conflict": "सिंक संघर्ष",
"Sync reading progress from \"{{deviceName}}\"?": "क्या आप \"{{deviceName}}\" से पढ़ने की प्रगति को सिंक करना चाहते हैं?",
"another device": "दूसरा डिवाइस",
"Local Progress": "स्थानीय प्रगति",
"Remote Progress": "दूरस्थ प्रगति",
"Page {{page}} of {{total}} ({{percentage}}%)": "पृष्ठ {{page}} कुल {{total}} ({{percentage}}%)",
"Current position": "वर्तमान स्थिति",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "लगभग पृष्ठ {{page}} कुल {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "लगभग {{percentage}}%",
"Failed to connect": "कनेक्ट करने में विफल",
"Sync Server Connected": "सिंक सर्वर कनेक्टेड",
"Precision: {{precision}} digits after the decimal": "सटीकता: {{precision}} दशमलव के बाद अंक",
"Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}"
}
@@ -95,7 +95,6 @@
"Speak": "Bicara",
"Subjects": "Subjek",
"System Fonts": "Font Sistem",
"TTS not supported in this device": "TTS tidak didukung di perangkat ini",
"Theme Color": "Warna Tema",
"Theme Mode": "Mode Tema",
"Translate": "Terjemahkan",
@@ -310,7 +309,6 @@
"Synced at {{time}}": "Disinkronkan pada {{time}}",
"Never synced": "Belum pernah disinkronkan",
"Show Remaining Time": "Perlihatkan Waktu Tersisa",
"Show Page Number": "Perlihatkan Nomor Halaman",
"{{time}} min left in chapter": "{{time}} menit tersisa di bab",
"Override Book Color": "Ubah Warna Buku",
"Login Required": "Perlu masuk",
@@ -407,7 +405,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 5 GB.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 20 GB.",
"Deleted cloud backup of the book: {{title}}": "Cadangan cloud buku dihapus: {{title}}",
"Failed to delete cloud backup of the book": "Cadangan cloud buku gagal dihapus",
"Are you sure to delete the cloud backup of the selected book?": "Apakah Anda yakin ingin menghapus cadangan cloud dari buku yang dipilih?",
"What's New in Readest": "Yang Baru di Readest",
"Enter book title": "Masukkan judul buku",
@@ -457,5 +454,51 @@
"Storage": "Penyimpanan",
"Terms of Service": "Ketentuan Layanan",
"Privacy Policy": "Kebijakan Privasi",
"Disable Double Click": "Nonaktifkan Klik Ganda"
"Disable Double Click": "Nonaktifkan Klik Ganda",
"TTS not supported for this document": "TTS tidak didukung untuk dokumen ini",
"Reset Password": "Reset Kata Sandi",
"Show Reading Progress": "Perlihatkan Progres Membaca",
"Reading Progress Style": "Gaya Progres Membaca",
"Page Number": "Nomor Halaman",
"Percentage": "Persentase",
"Deleted local copy of the book: {{title}}": "Salinan lokal buku dihapus: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Gagal menghapus cadangan cloud buku: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Gagal menghapus salinan lokal buku: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Apakah Anda yakin ingin menghapus salinan lokal buku yang dipilih?",
"Remove from Cloud & Device": "Hapus dari Cloud & Perangkat",
"Remove from Cloud Only": "Hapus dari Cloud Saja",
"Remove from Device Only": "Hapus dari Perangkat Saja",
"Disconnected": "Terputus",
"KOReader Sync Settings": "Pengaturan Sinkronisasi KOReader",
"Sync Strategy": "Strategi Sinkronisasi",
"Ask on conflict": "Tanya saat terjadi konflik",
"Always use latest": "Selalu gunakan yang terbaru",
"Send changes only": "Kirim perubahan saja",
"Receive changes only": "Terima perubahan saja",
"Disabled": "Dinonaktifkan",
"Checksum Method": "Metode Checksum",
"File Content (recommended)": "Konten File (disarankan)",
"File Name": "Nama File",
"Device Name": "Nama Perangkat",
"Sync Tolerance": "Toleransi Sinkronisasi",
"Connect to your KOReader Sync server.": "Hubungkan ke server Sinkronisasi KOReader Anda.",
"Server URL": "URL Server",
"Username": "Nama Pengguna",
"Your Username": "Nama Pengguna Anda",
"Password": "Kata Sandi",
"Connect": "Hubungkan",
"KOReader Sync": "Sinkronisasi KOReader",
"Sync Conflict": "Konflik Sinkronisasi",
"Sync reading progress from \"{{deviceName}}\"?": "Sinkronkan progres membaca dari \"{{deviceName}}\"?",
"another device": "perangkat lain",
"Local Progress": "Progres Lokal",
"Remote Progress": "Progres Jarak Jauh",
"Page {{page}} of {{total}} ({{percentage}}%)": "Halaman {{page}} dari {{total}} ({{percentage}}%)",
"Current position": "Posisi Saat Ini",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Sekitar halaman {{page}} dari {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Sekitar {{percentage}}%",
"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}}"
}
@@ -95,7 +95,6 @@
"Speak": "Leggi",
"Subjects": "Argomenti",
"System Fonts": "Font di sistema",
"TTS not supported in this device": "TTS non supportato su questo dispositivo",
"Theme Color": "Colore tema",
"Theme Mode": "Modalità tema",
"Translate": "Traduci",
@@ -314,7 +313,6 @@
"Synced at {{time}}": "Sincronizzato alle {{time}}",
"Never synced": "Mai sincronizzato",
"Show Remaining Time": "Mostra tempo rimanente",
"Show Page Number": "Mostra numero pagina",
"{{time}} min left in chapter": "{{time}} min rimasti nel capitolo",
"Override Book Color": "Override colore libro",
"Login Required": "Accesso richiesto",
@@ -415,7 +413,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 5 GB di storage cloud.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 20 GB di storage cloud.",
"Deleted cloud backup of the book: {{title}}": "Backup cloud del libro eliminato: {{title}}",
"Failed to delete cloud backup of the book": "Impossibile eliminare il backup cloud del libro",
"Are you sure to delete the cloud backup of the selected book?": "Sei sicuro di voler eliminare il backup cloud del libro selezionato?",
"What's New in Readest": "Novità in Readest",
"Enter book title": "Inserisci il titolo del libro",
@@ -465,5 +462,51 @@
"Storage": "Archiviazione",
"Terms of Service": "Termini di servizio",
"Privacy Policy": "Informativa sulla privacy",
"Disable Double Click": "Disabilita Doppio Clic"
"Disable Double Click": "Disabilita Doppio Clic",
"TTS not supported for this document": "TTS non supportato per questo documento",
"Reset Password": "Reimposta Password",
"Show Reading Progress": "Mostra Progresso Lettura",
"Reading Progress Style": "Stile di Progresso Lettura",
"Page Number": "Numero di Pagina",
"Percentage": "Percentuale",
"Deleted local copy of the book: {{title}}": "Copie locale del libro eliminata: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Impossibile eliminare il backup cloud del libro: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Impossibile eliminare la copia locale del libro: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Sei sicuro di voler eliminare la copia locale del libro selezionato?",
"Remove from Cloud & Device": "Rimuovi da Cloud & Dispositivo",
"Remove from Cloud Only": "Rimuovi solo da Cloud",
"Remove from Device Only": "Rimuovi solo da Dispositivo",
"Disconnected": "Disconnesso",
"KOReader Sync Settings": "Impostazioni di Sincronizzazione KOReader",
"Sync Strategy": "Strategia di Sincronizzazione",
"Ask on conflict": "Chiedi in caso di conflitto",
"Always use latest": "Usa sempre l'ultima versione",
"Send changes only": "Invia solo le modifiche",
"Receive changes only": "Ricevi solo le modifiche",
"Disabled": "Disabilitato",
"Checksum Method": "Metodo di Controllo",
"File Content (recommended)": "Contenuto del File (consigliato)",
"File Name": "Nome del File",
"Device Name": "Nome del Dispositivo",
"Sync Tolerance": "Tolleranza di Sincronizzazione",
"Connect to your KOReader Sync server.": "Connettiti al tuo server KOReader Sync.",
"Server URL": "URL del Server",
"Username": "Nome Utente",
"Your Username": "Il tuo Nome Utente",
"Password": "Password",
"Connect": "Connetti",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Conflitto di Sincronizzazione",
"Sync reading progress from \"{{deviceName}}\"?": "Sincronizzare il progresso di lettura da \"{{deviceName}}\"?",
"another device": "un altro dispositivo",
"Local Progress": "Progresso Locale",
"Remote Progress": "Progresso Remoto",
"Page {{page}} of {{total}} ({{percentage}}%)": "Pagina {{page}} di {{total}} ({{percentage}}%)",
"Current position": "Posizione Corrente",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Circa pagina {{page}} di {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Circa {{percentage}}%",
"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}}"
}
@@ -95,7 +95,6 @@
"Speak": "読み上げ",
"Subjects": "主題",
"System Fonts": "システムフォント",
"TTS not supported in this device": "このデバイスではTTSがサポートされていません",
"Theme Color": "テーマカラー",
"Theme Mode": "テーマモード",
"Translate": "翻訳",
@@ -310,7 +309,6 @@
"Synced at {{time}}": "{{time}} に同期済み",
"Never synced": "まだ同期されていません",
"Show Remaining Time": "残り時間を表示",
"Show Page Number": "ページ番号を表示",
"{{time}} min left in chapter": "{{time}}分残り",
"Override Book Color": "書籍の色を上書き",
"Login Required": "ログイン必須",
@@ -407,7 +405,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "最大5GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "最大20GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
"Deleted cloud backup of the book: {{title}}": "書籍のクラウドバックアップを削除しました:{{title}}",
"Failed to delete cloud backup of the book": "書籍のクラウドバックアップを削除できませんでした",
"Are you sure to delete the cloud backup of the selected book?": "選択した書籍のクラウドバックアップを削除してもよろしいですか?",
"What's New in Readest": "Readestの新機能",
"Enter book title": "本のタイトルを入力",
@@ -457,5 +454,51 @@
"Storage": "ストレージ",
"Terms of Service": "利用規約",
"Privacy Policy": "プライバシーポリシー",
"Disable Double Click": "ダブルクリック無効"
"Disable Double Click": "ダブルクリック無効",
"TTS not supported for this document": "TTSはこのドキュメントではサポートされていません",
"Reset Password": "パスワードをリセット",
"Show Reading Progress": "読書進捗を表示",
"Reading Progress Style": "読書進捗スタイル",
"Page Number": "ページ番号",
"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": "切断されました",
"KOReader Sync Settings": "KOReader Sync設定",
"Sync Strategy": "同期戦略",
"Ask on conflict": "競合時に確認",
"Always use latest": "最新を常に使用",
"Send changes only": "変更のみを送信",
"Receive changes only": "変更のみを受信",
"Disabled": "無効",
"Checksum Method": "チェックサム方式",
"File Content (recommended)": "ファイル内容(推奨)",
"File Name": "ファイル名",
"Device Name": "デバイス名",
"Sync Tolerance": "同期許容範囲",
"Connect to your KOReader Sync server.": "KOReader Syncサーバーに接続します。",
"Server URL": "サーバーURL",
"Username": "ユーザー名",
"Your Username": "あなたのユーザー名",
"Password": "パスワード",
"Connect": "接続",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "同期競合",
"Sync reading progress from \"{{deviceName}}\"?": "{{deviceName}}から読書進捗を同期しますか?",
"another device": "別のデバイス",
"Local Progress": "ローカル進捗",
"Remote Progress": "リモート進捗",
"Page {{page}} of {{total}} ({{percentage}}%)": "ページ {{page}} / {{total}} ({{percentage}}%)",
"Current position": "現在の位置",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "おおよそページ {{page}} / {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "おおよそ {{percentage}}%",
"Failed to connect": "接続に失敗しました",
"Sync Server Connected": "同期サーバーに接続されました",
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 桁の小数点以下",
"Sync as {{userDisplayName}}": "{{userDisplayName}} として同期"
}
@@ -95,7 +95,6 @@
"Speak": "말하기",
"Subjects": "주제",
"System Fonts": "시스템 글꼴",
"TTS not supported in this device": "이 장치에서 TTS가 지원되지 않음",
"Theme Color": "테마 색상",
"Theme Mode": "테마 모드",
"Translate": "번역",
@@ -310,7 +309,6 @@
"Synced at {{time}}": "{{time}}에 동기화됨",
"Never synced": "동기화된 적 없음",
"Show Remaining Time": "남은 시간 표시",
"Show Page Number": "페이지 번호 표시",
"{{time}} min left in chapter": "{{time}}분 남음",
"Override Book Color": "책 색상 덮어쓰기",
"Login Required": "로그인 필요",
@@ -407,7 +405,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "최대 5GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "최대 20GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
"Deleted cloud backup of the book: {{title}}": "책의 클라우드 백업이 삭제됨: {{title}}",
"Failed to delete cloud backup of the book": "책의 클라우드 백업을 삭제하지 못했습니다.",
"Are you sure to delete the cloud backup of the selected book?": "선택한 책의 클라우드 백업을 삭제하시겠습니까?",
"What's New in Readest": "Readest의 새로운 기능",
"Enter book title": "도서 제목 입력",
@@ -457,5 +454,51 @@
"Storage": "저장소",
"Terms of Service": "서비스 약관",
"Privacy Policy": "개인정보 보호정책",
"Disable Double Click": "더블 클릭 비활성화"
"Disable Double Click": "더블 클릭 비활성화",
"TTS not supported for this document": "TTS가 이 문서에서 지원되지 않습니다",
"Reset Password": "비밀번호 재설정",
"Show Reading Progress": "읽기 진행 상황 표시",
"Reading Progress Style": "읽기 진행 상황 스타일",
"Page Number": "페이지 번호",
"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": "연결 끊김",
"KOReader Sync Settings": "KOReader Sync 설정",
"Sync Strategy": "동기화 전략",
"Ask on conflict": "충돌 시 묻기",
"Always use latest": "항상 최신 버전 사용",
"Send changes only": "변경 사항만 전송",
"Receive changes only": "변경 사항만 수신",
"Disabled": "비활성화",
"Checksum Method": "체크섬 방법",
"File Content (recommended)": "파일 내용 (권장)",
"File Name": "파일 이름",
"Device Name": "장치 이름",
"Sync Tolerance": "동기화 허용 오차",
"Connect to your KOReader Sync server.": "KOReader Sync 서버에 연결합니다.",
"Server URL": "서버 URL",
"Username": "사용자 이름",
"Your Username": "당신의 사용자 이름",
"Password": "비밀번호",
"Connect": "연결",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "동기화 충돌",
"Sync reading progress from \"{{deviceName}}\"?": " \"{{deviceName}}\"에서 읽기 진행 상황을 동기화하시겠습니까?",
"another device": "다른 장치",
"Local Progress": "로컬 진행 상황",
"Remote Progress": "원격 진행 상황",
"Page {{page}} of {{total}} ({{percentage}}%)": "페이지 {{page}} / {{total}} ({{percentage}}%)",
"Current position": "현재 위치",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "대략 페이지 {{page}} / {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "대략 {{percentage}}%",
"Failed to connect": "연결 실패",
"Sync Server Connected": "동기화 서버에 연결됨",
"Precision: {{precision}} digits after the decimal": "정밀도: {{precision}} 자리의 소수점 이하",
"Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화"
}
@@ -213,7 +213,6 @@
"TOC": "Inhoudsopgave",
"Sidebar": "Zijbalk",
"TTS not supported for PDF": "TTS niet ondersteund voor PDF",
"TTS not supported in this device": "TTS niet ondersteund op dit apparaat",
"No Timeout": "Geen time-out",
"{{value}} minute": "{{value}} minuut",
"{{value}} minutes": "{{value}} minuten",
@@ -303,7 +302,7 @@
"Security": "Beveiliging",
"Allow JavaScript": "JavaScript toestaan",
"Enable only if you trust the file.": "Schakel alleen in als u het bestand vertrouwt.",
"Sort TOC by Page": "__STRING_NOT_TRANSLATED__",
"Sort TOC by Page": "TOC sorteren op pagina",
"Search in {{count}} Book(s)..._one": "Zoeken in {{count}} Boek...",
"Search in {{count}} Book(s)..._other": "Zoeken in {{count}} Boeken...",
"No notes match your search": "Geen notities gevonden",
@@ -312,7 +311,6 @@
"Synced at {{time}}": "Gesynchroniseerd om {{time}}",
"Never synced": "Nooit gesynchroniseerd",
"Show Remaining Time": "Toon resterende tijd",
"Show Page Number": "Toon paginanummer",
"{{time}} min left in chapter": "Nog {{time}} min in hoofdstuk",
"Override Book Color": "Boekkleur overschrijven",
"Login Required": "Inloggen vereist",
@@ -411,7 +409,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 5 GB cloudopslag.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 20 GB cloudopslag.",
"Deleted cloud backup of the book: {{title}}": "Verwijderde cloudback-up van het boek: {{title}}",
"Failed to delete cloud backup of the book": "Verwijderen van cloudback-up van het boek is mislukt.",
"Are you sure to delete the cloud backup of the selected book?": "Weet je zeker dat je de cloudback-up van het geselecteerde boek wilt verwijderen?",
"What's New in Readest": "Wat is er nieuw in Readest",
"Enter book title": "Voer boektitel in",
@@ -461,5 +458,51 @@
"Storage": "Opslag",
"Terms of Service": "Gebruiksvoorwaarden",
"Privacy Policy": "Privacybeleid",
"Disable Double Click": "Dubbelklikken uitschakelen"
"Disable Double Click": "Dubbelklikken uitschakelen",
"TTS not supported for this document": "TTS niet ondersteund voor dit document",
"Reset Password": "Wachtwoord resetten",
"Show Reading Progress": "Leesvoortgang weergeven",
"Reading Progress Style": "Stijl van leesvoortgang",
"Page Number": "Pagina nummer",
"Percentage": "Percentage",
"Deleted local copy of the book: {{title}}": "Verwijderde lokale kopie van het boek: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Verwijderen van cloudback-up van het boek is mislukt: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Verwijderen van lokale kopie van het boek is mislukt: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Weet je zeker dat je de lokale kopie van het geselecteerde boek wilt verwijderen?",
"Remove from Cloud & Device": "Verwijder uit Cloud & Apparaat",
"Remove from Cloud Only": "Verwijder alleen uit Cloud",
"Remove from Device Only": "Verwijder alleen uit Apparaat",
"Disconnected": "Verbroken",
"KOReader Sync Settings": "KOReader Sync-instellingen",
"Sync Strategy": "Synchronisatiestrategie",
"Ask on conflict": "Vraag bij conflict",
"Always use latest": "Altijd de nieuwste gebruiken",
"Send changes only": "Verzend alleen wijzigingen",
"Receive changes only": "Ontvang alleen wijzigingen",
"Disabled": "Uitgeschakeld",
"Checksum Method": "Checksum-methode",
"File Content (recommended)": "Bestandsinhoud (aanbevolen)",
"File Name": "Bestandsnaam",
"Device Name": "Apparaatnaam",
"Sync Tolerance": "Synchronisatietolerantie",
"Connect to your KOReader Sync server.": "Verbind met je KOReader Sync-server.",
"Server URL": "Server-URL",
"Username": "Gebruikersnaam",
"Your Username": "Je gebruikersnaam",
"Password": "Wachtwoord",
"Connect": "Verbinden",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Synchronisatieconflict",
"Sync reading progress from \"{{deviceName}}\"?": "Leesvoortgang synchroniseren van \"{{deviceName}}\"?",
"another device": "een ander apparaat",
"Local Progress": "Lokale voortgang",
"Remote Progress": "Externe voortgang",
"Page {{page}} of {{total}} ({{percentage}}%)": "Pagina {{page}} van {{total}} ({{percentage}}%)",
"Current position": "Huidige positie",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Ongeveer pagina {{page}} van {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Ongeveer {{percentage}}%",
"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}}"
}
@@ -95,7 +95,6 @@
"Speak": "Czytaj",
"Subjects": "Tematy",
"System Fonts": "Czcionki systemowe",
"TTS not supported in this device": "TTS nie jest obsługiwane na tym urządzeniu",
"Theme Color": "Kolor motywu",
"Theme Mode": "Tryb motywu",
"Translate": "Tłumacz",
@@ -316,7 +315,6 @@
"Synced at {{time}}": "Sync: {{time}}",
"Never synced": "Brak synchronizacji",
"Show Remaining Time": "Pokazuj pozostały czas",
"Show Page Number": "Pokaż numer strony",
"{{time}} min left in chapter": "{{time}} min pozostało do końca rozdziału",
"Override Book Color": "Nadpisz kolor książki",
"Login Required": "Wymagane logowanie",
@@ -419,7 +417,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 5 GB pamięci w chmurze.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 20 GB pamięci w chmurze.",
"Deleted cloud backup of the book: {{title}}": "Usunięto kopię zapasową książki w chmurze: {{title}}",
"Failed to delete cloud backup of the book": "Nie udało się usunąć kopii zapasowej książki w chmurze.",
"Are you sure to delete the cloud backup of the selected book?": "Czy na pewno chcesz usunąć kopię zapasową chmury wybranej książki?",
"What's New in Readest": "Co nowego w Readest",
"Enter book title": "Wprowadź tytuł książki",
@@ -469,5 +466,51 @@
"Storage": "Przechowywanie",
"Terms of Service": "Warunki korzystania z usługi",
"Privacy Policy": "Polityka prywatności",
"Disable Double Click": "Wyłącz podwójne kliknięcie"
"Disable Double Click": "Wyłącz podwójne kliknięcie",
"TTS not supported for this document": "TTS nie jest obsługiwane dla tego dokumentu",
"Reset Password": "Zresetuj hasło",
"Show Reading Progress": "Pokazuj postęp czytania",
"Reading Progress Style": "Styl postępu czytania",
"Page Number": "Numer strony",
"Percentage": "Procent",
"Deleted local copy of the book: {{title}}": "Usunięto lokalną kopię książki: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Nie udało się usunąć kopii zapasowej w chmurze książki: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Nie udało się usunąć lokalnej kopii książki: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Czy na pewno chcesz usunąć lokalną kopię wybranej książki?",
"Remove from Cloud & Device": "Usuń z chmury i urządzenia",
"Remove from Cloud Only": "Usuń tylko z chmury",
"Remove from Device Only": "Usuń tylko z urządzenia",
"Disconnected": "Rozłączono",
"KOReader Sync Settings": "Ustawienia synchronizacji KOReader",
"Sync Strategy": "Strategia synchronizacji",
"Ask on conflict": "Pytaj w przypadku konfliktu",
"Always use latest": "Zawsze używaj najnowszej",
"Send changes only": "Wyślij tylko zmiany",
"Receive changes only": "Odbierz tylko zmiany",
"Disabled": "Wyłączone",
"Checksum Method": "Metoda sumy kontrolnej",
"File Content (recommended)": "Zawartość pliku (zalecane)",
"File Name": "Nazwa pliku",
"Device Name": "Nazwa urządzenia",
"Sync Tolerance": "Tolerancja synchronizacji",
"Connect to your KOReader Sync server.": "Połącz z serwerem synchronizacji KOReader.",
"Server URL": "Adres URL serwera",
"Username": "Nazwa użytkownika",
"Your Username": "Twoja nazwa użytkownika",
"Password": "Hasło",
"Connect": "Połącz",
"KOReader Sync": "Synchronizacja KOReader",
"Sync Conflict": "Konflikt synchronizacji",
"Sync reading progress from \"{{deviceName}}\"?": "Synchronizować postęp czytania z \"{{deviceName}}\"?",
"another device": "inne urządzenie",
"Local Progress": "Postęp lokalny",
"Remote Progress": "Postęp zdalny",
"Page {{page}} of {{total}} ({{percentage}}%)": "Strona {{page}} z {{total}} ({{percentage}}%)",
"Current position": "Bieżąca pozycja",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Około strona {{page}} z {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Około {{percentage}}%",
"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}}"
}
@@ -95,7 +95,6 @@
"Speak": "Falar",
"Subjects": "Assuntos",
"System Fonts": "Fontes do Sistema",
"TTS not supported in this device": "TTS não suportado neste dispositivo",
"Theme Color": "Cor do Tema",
"Theme Mode": "Modo do Tema",
"Translate": "Traduzir",
@@ -314,7 +313,6 @@
"Synced at {{time}}": "Sincronizado às {{time}}",
"Never synced": "Nunca sincronizado",
"Show Remaining Time": "Mostrar Tempo Restante",
"Show Page Number": "Mostrar Número da Página",
"{{time}} min left in chapter": "{{time}} min restantes no capítulo",
"Override Book Color": "Substituir Cor do Livro",
"Login Required": "Login necessário",
@@ -415,7 +413,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 5 GB de armazenamento na nuvem.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 20 GB de armazenamento na nuvem.",
"Deleted cloud backup of the book: {{title}}": "Backup na nuvem do livro excluído: {{title}}",
"Failed to delete cloud backup of the book": "Falha ao excluir o backup na nuvem do livro.",
"Are you sure to delete the cloud backup of the selected book?": "Tem certeza de que deseja excluir o backup na nuvem do livro selecionado?",
"What's New in Readest": "Novidades no Readest",
"Enter book title": "Digite o título do livro",
@@ -465,5 +462,51 @@
"Storage": "Armazenamento",
"Terms of Service": "Termos de Serviço",
"Privacy Policy": "Política de Privacidade",
"Disable Double Click": "Desativar Clique Duplo"
"Disable Double Click": "Desativar Clique Duplo",
"TTS not supported for this document": "TTS não suportado para este documento",
"Reset Password": "Redefinir Senha",
"Show Reading Progress": "Mostrar Progresso de Leitura",
"Reading Progress Style": "Estilo de Progresso de Leitura",
"Page Number": "Número da Página",
"Percentage": "Porcentagem",
"Deleted local copy of the book: {{title}}": " cópia local do livro excluída: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Falha ao excluir backup na nuvem do livro: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Falha ao excluir cópia local do livro: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Tem certeza de que deseja excluir a cópia local do livro selecionado?",
"Remove from Cloud & Device": "Remover da Nuvem & Dispositivo",
"Remove from Cloud Only": "Remover apenas da Nuvem",
"Remove from Device Only": "Remover apenas do Dispositivo",
"Disconnected": "Desconectado",
"KOReader Sync Settings": "Configurações de Sincronização KOReader",
"Sync Strategy": "Estratégia de Sincronização",
"Ask on conflict": "Perguntar em caso de conflito",
"Always use latest": "Sempre usar o mais recente",
"Send changes only": "Enviar apenas alterações",
"Receive changes only": "Receber apenas alterações",
"Disabled": "Desativado",
"Checksum Method": "Método de Verificação",
"File Content (recommended)": "Conteúdo do Arquivo (recomendado)",
"File Name": "Nome do Arquivo",
"Device Name": "Nome do Dispositivo",
"Sync Tolerance": "Tolerância de Sincronização",
"Connect to your KOReader Sync server.": "Conectar ao seu servidor KOReader Sync.",
"Server URL": "URL do Servidor",
"Username": "Nome de Usuário",
"Your Username": "Seu Nome de Usuário",
"Password": "Senha",
"Connect": "Conectar",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Conflito de Sincronização",
"Sync reading progress from \"{{deviceName}}\"?": "Sincronizar progresso de leitura de \"{{deviceName}}\"?",
"another device": "outro dispositivo",
"Local Progress": "Progresso Local",
"Remote Progress": "Progresso Remoto",
"Page {{page}} of {{total}} ({{percentage}}%)": "Página {{page}} de {{total}} ({{percentage}}%)",
"Current position": "Posição Atual",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Aproximadamente página {{page}} de {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Aproximadamente {{percentage}}%",
"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}}"
}
@@ -95,7 +95,6 @@
"Speak": "Произнести",
"Subjects": "Темы",
"System Fonts": "Системные шрифты",
"TTS not supported in this device": "TTS не поддерживается на этом устройстве",
"Theme Color": "Цвет темы",
"Theme Mode": "Режим темы",
"Translate": "Перевести",
@@ -316,7 +315,6 @@
"Synced at {{time}}": "Синхр. в {{time}}",
"Never synced": "Не синхр.",
"Show Remaining Time": "Показать оставшееся время",
"Show Page Number": "Показать номер страницы",
"{{time}} min left in chapter": "{{time}} мин осталось в главе",
"Override Book Color": "Переопределить цвет книги",
"Login Required": "Требуется вход",
@@ -419,7 +417,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 5 ГБ облачного хранилища.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 20 ГБ облачного хранилища.",
"Deleted cloud backup of the book: {{title}}": "Удалено облачное резервное копирование книги: {{title}}",
"Failed to delete cloud backup of the book": "Не удалось удалить облачное резервное копирование книги.",
"Are you sure to delete the cloud backup of the selected book?": "Вы уверены, что хотите удалить облачное резервное копирование выбранной книги?",
"What's New in Readest": "Что нового в Readest",
"Enter book title": "Введите название книги",
@@ -469,5 +466,51 @@
"Storage": "Хранилище",
"Terms of Service": "Условия использования",
"Privacy Policy": "Политика конфиденциальности",
"Disable Double Click": "Отключить двойной клик"
"Disable Double Click": "Отключить двойной клик",
"TTS not supported for this document": "TTS не поддерживается для этого документа",
"Reset Password": "Сбросить пароль",
"Show Reading Progress": "Показать прогресс чтения",
"Reading Progress Style": "Стиль прогресса чтения",
"Page Number": "Номер страницы",
"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": "Отключено",
"KOReader Sync Settings": "Настройки синхронизации KOReader",
"Sync Strategy": "Стратегия синхронизации",
"Ask on conflict": "Спрашивать при конфликте",
"Always use latest": "Всегда использовать последнюю",
"Send changes only": "Отправлять только изменения",
"Receive changes only": "Получать только изменения",
"Disabled": "Отключено",
"Checksum Method": "Метод контрольной суммы",
"File Content (recommended)": "Содержимое файла (рекомендуется)",
"File Name": "Имя файла",
"Device Name": "Имя устройства",
"Sync Tolerance": "Допуск синхронизации",
"Connect to your KOReader Sync server.": "Подключитесь к вашему серверу синхронизации KOReader.",
"Server URL": "URL сервера",
"Username": "Имя пользователя",
"Your Username": "Ваше имя пользователя",
"Password": "Пароль",
"Connect": "Подключиться",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Конфликт синхронизации",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронизировать прогресс чтения с \"{{deviceName}}\"?",
"another device": "другом устройстве",
"Local Progress": "Локальный прогресс",
"Remote Progress": "Удаленный прогресс",
"Page {{page}} of {{total}} ({{percentage}}%)": "Страница {{page}} из {{total}} ({{percentage}}%)",
"Current position": "Текущая позиция",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизительно страница {{page}} из {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Приблизительно {{percentage}}%",
"Failed to connect": "Не удалось подключиться",
"Sync Server Connected": "Сервер синхронизации подключен",
"Precision: {{precision}} digits after the decimal": "Точность: {{precision}} знаков после запятой",
"Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}"
}
@@ -114,7 +114,6 @@
"Book deleted: {{title}}": "ลบหนังสือแล้ว: {{title}}",
"Failed to delete book: {{title}}": "ไม่สามารถลบหนังสือ: {{title}}",
"Deleted cloud backup of the book: {{title}}": "ลบสำรองบนคลาวด์แล้ว: {{title}}",
"Failed to delete cloud backup of the book": "ไม่สามารถลบสำรองบนคลาวด์",
"Welcome to your library. You can import your books here and read them anytime.": "ยินดีต้อนรับสู่คลังหนังสือ คุณสามารถนำเข้าหนังสือและอ่านได้ตลอดเวลา",
"Copied to notebook": "คัดลอกไปยังสมุดบันทึก",
"No annotations to export": "ไม่มีบันทึกให้ส่งออก",
@@ -237,7 +236,6 @@
"Show Footer": "แสดงท้ายกระดาษ",
"Show Remaining Time": "แสดงเวลาที่เหลือ",
"Show Remaining Pages": "แสดงหน้าที่เหลือ",
"Show Page Number": "แสดงเลขหน้า",
"Apply also in Scrolled Mode": "ใช้ในโหมดเลื่อนด้วย",
"Screen": "หน้าจอ",
"Orientation": "การวางแนว",
@@ -280,7 +278,6 @@
"Disable Translation": "ปิดการแปล",
"Translation Not Available": "ไม่มีการแปล",
"TTS not supported for PDF": "ไม่รองรับ TTS สำหรับ PDF",
"TTS not supported in this device": "อุปกรณ์นี้ไม่รองรับ TTS",
"No Timeout": "ไม่จำกัดเวลา",
"{{value}} minute": "{{value}} นาที",
"{{value}} minutes": "{{value}} นาที",
@@ -457,5 +454,51 @@
"Storage": "พื้นที่เก็บข้อมูล",
"Terms of Service": "ข้อกำหนดในการให้บริการ",
"Privacy Policy": "นโยบายความเป็นส่วนตัว",
"Disable Double Click": "ปิดการคลิกสองครั้ง"
"Disable Double Click": "ปิดการคลิกสองครั้ง",
"TTS not supported for this document": "ไม่รองรับ TTS สำหรับเอกสารนี้",
"Reset Password": "รีเซ็ตพาสเวิร์ด",
"Show Reading Progress": "แสดงความคืบหน้าในการอ่าน",
"Reading Progress Style": "สไตล์ความก้าวหน้าในการอ่าน",
"Page Number": "หมายเลขหน้า",
"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": "ตัดการเชื่อมต่อ",
"KOReader Sync Settings": "การตั้งค่าการซิงค์ KOReader",
"Sync Strategy": "กลยุทธ์การซิงค์",
"Ask on conflict": "ถามเมื่อเกิดความขัดแย้ง",
"Always use latest": "ใช้เวอร์ชันล่าสุดเสมอ",
"Send changes only": "ส่งการเปลี่ยนแปลงเท่านั้น",
"Receive changes only": "รับการเปลี่ยนแปลงเท่านั้น",
"Disabled": "ปิดใช้งาน",
"Checksum Method": "วิธีการตรวจสอบความถูกต้อง",
"File Content (recommended)": "เนื้อหาไฟล์ (แนะนำ)",
"File Name": "ชื่อไฟล์",
"Device Name": "ชื่ออุปกรณ์",
"Sync Tolerance": "ความทนทานต่อการซิงค์",
"Connect to your KOReader Sync server.": "เชื่อมต่อกับเซิร์ฟเวอร์ KOReader Sync ของคุณ",
"Server URL": "URL ของเซิร์ฟเวอร์",
"Username": "ชื่อผู้ใช้",
"Your Username": "ชื่อผู้ใช้ของคุณ",
"Password": "รหัสผ่าน",
"Connect": "เชื่อมต่อ",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "ความขัดแย้งในการซิงค์",
"Sync reading progress from \"{{deviceName}}\"?": "ซิงค์ความก้าวหน้าในการอ่านจาก \"{{deviceName}}\"?",
"another device": "อุปกรณ์อื่น",
"Local Progress": "ความก้าวหน้าในเครื่อง",
"Remote Progress": "ความก้าวหน้าในคลาวด์",
"Page {{page}} of {{total}} ({{percentage}}%)": "หน้า {{page}} จาก {{total}} ({{percentage}}%)",
"Current position": "ตำแหน่งปัจจุบัน",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ประมาณหน้า {{page}} จาก {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "ประมาณ {{percentage}}%",
"Failed to connect": "ไม่สามารถเชื่อมต่อได้",
"Sync Server Connected": "เซิร์ฟเวอร์ซิงค์เชื่อมต่อแล้ว",
"Precision: {{precision}} digits after the decimal": "ความแม่นยำ: {{precision}} หลักหลังจุดทศนิยม",
"Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}"
}
@@ -95,7 +95,6 @@
"Speak": "Konuş",
"Subjects": "Konular",
"System Fonts": "Sistem Yazı Tipleri",
"TTS not supported in this device": "Bu cihazda TTS desteklenmiyor",
"Theme Color": "Tema Rengi",
"Theme Mode": "Tema Modu",
"Translate": "Çevir",
@@ -312,7 +311,6 @@
"Synced at {{time}}": "{{time}}'de eşzlendi",
"Never synced": "Hiç eşzlenmedi",
"Show Remaining Time": "Kalan Süreyi Göster",
"Show Page Number": "Sayfa Numarasını Göster",
"{{time}} min left in chapter": "{{time}} dakika bölümde kaldı",
"Override Book Color": "Kitap Rengini Geçersiz Kıl",
"Login Required": "Giriş gerekli",
@@ -411,7 +409,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 5 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 20 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
"Deleted cloud backup of the book: {{title}}": "Kitabın bulut yedeği silindi: {{title}}",
"Failed to delete cloud backup of the book": "Kitabın bulut yedeğini silme işlemi başarısız oldu.",
"Are you sure to delete the cloud backup of the selected book?": "Seçilen kitabın bulut yedeğini silmek istediğinize emin misiniz?",
"What's New in Readest": "Readest'te Neler Yeni",
"Enter book title": "Kitap başlığını girin",
@@ -461,5 +458,51 @@
"Storage": "Depolama",
"Terms of Service": "Hizmet Şartları",
"Privacy Policy": "Gizlilik Politikası",
"Disable Double Click": "Çift Tıklamayı Devre Dışı Bırak"
"Disable Double Click": "Çift Tıklamayı Devre Dışı Bırak",
"TTS not supported for this document": "TTS bu belge için desteklenmiyor",
"Reset Password": "Şifreyi Sıfırla",
"Show Reading Progress": "Okuma İlerlemesini Göster",
"Reading Progress Style": "Okuma İlerlemesi Stili",
"Page Number": "Sayfa Numarası",
"Percentage": "Yüzde",
"Deleted local copy of the book: {{title}}": "Kitabın yerel kopyası silindi: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Kitabın bulut yedeğini silme başarısız oldu: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Kitabın yerel kopyasını silme başarısız oldu: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Seçilen kitabın yerel kopyasını silmek istediğinize emin misiniz?",
"Remove from Cloud & Device": "Buluttan ve Cihazdan Kaldır",
"Remove from Cloud Only": "Sadece Buluttan Kaldır",
"Remove from Device Only": "Sadece Cihazdan Kaldır",
"Disconnected": "Bağlantı Kesildi",
"KOReader Sync Settings": "KOReader Senkronizasyon Ayarları",
"Sync Strategy": "Senkronizasyon Stratejisi",
"Ask on conflict": "Çatışmada Sor",
"Always use latest": "Her Zaman En Sonunu Kullan",
"Send changes only": "Sadece Değişiklikleri Gönder",
"Receive changes only": "Sadece Değişiklikleri Al",
"Disabled": "Devre Dışı",
"Checksum Method": "Kontrol Toplamı Yöntemi",
"File Content (recommended)": "Dosya İçeriği (önerilen)",
"File Name": "Dosya Adı",
"Device Name": "Cihaz Adı",
"Sync Tolerance": "Senkronizasyon Toleransı",
"Connect to your KOReader Sync server.": "KOReader Senkronizasyon sunucunuza bağlanın.",
"Server URL": "Sunucu URL'si",
"Username": "Kullanıcı Adı",
"Your Username": "Kullanıcı Adınız",
"Password": "Şifre",
"Connect": "Bağlan",
"KOReader Sync": "KOReader Senkronizasyon",
"Sync Conflict": "Senkronizasyon Çatışması",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" cihazından okuma ilerlemesini senkronize etmek istiyor musunuz?",
"another device": "başka bir cihaz",
"Local Progress": "Yerel İlerleme",
"Remote Progress": "Uzak İlerleme",
"Page {{page}} of {{total}} ({{percentage}}%)": "Sayfa {{page}} / {{total}} ({{percentage}}%)",
"Current position": "Mevcut konum",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Yaklaşık sayfa {{page}} / {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Yaklaşık {{percentage}}%",
"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"
}
@@ -95,7 +95,6 @@
"Speak": "Озвучити",
"Subjects": "Теми",
"System Fonts": "Системні шрифти",
"TTS not supported in this device": "TTS не підтримується на цьому пристрої",
"Theme Color": "Колір теми",
"Theme Mode": "Режим теми",
"Translate": "Перекласти",
@@ -316,7 +315,6 @@
"Synced at {{time}}": "Синхр. о {{time}}",
"Never synced": "Ніколи не синхр.",
"Show Remaining Time": "Показати залишок часу",
"Show Page Number": "Показати номер сторінки",
"{{time}} min left in chapter": "{{time}} хв до кінця розділу",
"Override Book Color": "Перевизначити колір книги",
"Login Required": "Потрібен вхід",
@@ -419,7 +417,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 5 ГБ хмарного сховища.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 20 ГБ хмарного сховища.",
"Deleted cloud backup of the book: {{title}}": "Видалено хмарну резервну копію книги: {{title}}",
"Failed to delete cloud backup of the book": "Не вдалося видалити хмарну резервну копію.",
"Are you sure to delete the cloud backup of the selected book?": "Ви впевнені, що хочете видалити хмарну резервну копію вибраної книги?",
"What's New in Readest": "Що нового в Readest",
"Enter book title": "Введіть назву книги",
@@ -457,17 +454,63 @@
"Select Cover Image": "Оберіть зображення обкладинки",
"Open Book in New Window": "Відкрити книгу в новому вікні",
"Voices for {{lang}}": "Голоси для {{lang}}",
"Yandex Translate": "Yandex Перекладач",
"Yandex Translate": "Yandex Перекладач (росія)",
"YYYY or YYYY-MM-DD": "YYYY або YYYY-MM-DD",
"Restore Purchase": "Відновити покупку",
"No purchases found to restore.": "Не знайдено покупок для відновлення.",
"Failed to restore purchases.": "Не вдалося відновити покупки.",
"Failed to manage subscription.": "Не вдалося керувати підпискою.",
"Failed to manage subscription.": "Не вдалося відредагувати підписку.",
"Failed to load subscription plans.": "Не вдалося завантажити плани підписки.",
"year": "рік",
"Failed to create checkout session": "Не вдалося створити сесію оформлення замовлення",
"Storage": "Сховище",
"Terms of Service": "Умови обслуговування",
"Privacy Policy": "Політика конфіденційності",
"Disable Double Click": "Вимкнути подвійне клацання"
"Disable Double Click": "Вимкнути подвійне натискання",
"TTS not supported for this document": "TTS не підтримується в цьому документі",
"Reset Password": "Скинути пароль",
"Show Reading Progress": "Показати прогрес читання",
"Reading Progress Style": "Стиль прогресу читання",
"Page Number": "Номер сторінки",
"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": "Відключено",
"KOReader Sync Settings": "Налаштування синхронізації KOReader",
"Sync Strategy": "Стратегія синхронізації",
"Ask on conflict": "Запитувати при конфлікті",
"Always use latest": "Завжди використовувати останню версію",
"Send changes only": "Відправити тільки зміни",
"Receive changes only": "Отримати тільки зміни",
"Disabled": "Вимкнено",
"Checksum Method": "Метод контрольної суми",
"File Content (recommended)": "Вміст файлу (рекомендується)",
"File Name": "Ім'я файлу",
"Device Name": "Ім'я пристрою",
"Sync Tolerance": "Допустима похибка синхронізації",
"Connect to your KOReader Sync server.": "Підключіться до свого сервера синхронізації KOReader.",
"Server URL": "URL сервера",
"Username": "Ім'я користувача",
"Your Username": "Ваше ім'я користувача",
"Password": "Пароль",
"Connect": "Підключитися",
"KOReader Sync": "Синхронізація KOReader",
"Sync Conflict": "Конфлікт синхронізації",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання з \"{{deviceName}}\"?",
"another device": "інший пристрій",
"Local Progress": "Локальний прогрес",
"Remote Progress": "Віддалений прогрес",
"Page {{page}} of {{total}} ({{percentage}}%)": "Сторінка {{page}} з {{total}} ({{percentage}}%)",
"Current position": "Поточна позиція",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} з {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Приблизно {{percentage}}%",
"Failed to connect": "Не вдалося підключитися",
"Sync Server Connected": "Сервер синхронізації підключено",
"Precision: {{precision}} digits after the decimal": "Точність: {{precision}} знаків після коми",
"Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}"
}
@@ -95,7 +95,6 @@
"Speak": "Đọc",
"Subjects": "Chủ đề",
"System Fonts": "Phông chữ hệ thống",
"TTS not supported in this device": "TTS không được hỗ trợ trên thiết bị này",
"Theme Color": "Màu chủ đề",
"Theme Mode": "Chế độ chủ đề",
"Translate": "Dịch",
@@ -310,7 +309,6 @@
"Synced at {{time}}": "Đã đồng bộ lúc {{time}}",
"Never synced": "Chưa từng đồng bộ",
"Show Remaining Time": "Hiển thị thời gian còn lại",
"Show Page Number": "Hiển thị số trang",
"{{time}} min left in chapter": "{{time}} phút còn lại trong chương",
"Override Book Color": "Đổi màu sách",
"Login Required": "Cần đăng nhập",
@@ -407,7 +405,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 5 GB lưu trữ đám mây.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 20 GB lưu trữ đám mây.",
"Deleted cloud backup of the book: {{title}}": "Đã xóa bản sao lưu đám mây của sách: {{title}}",
"Failed to delete cloud backup of the book": "Không thể xóa bản sao lưu đám mây của sách.",
"Are you sure to delete the cloud backup of the selected book?": "Bạn có chắc chắn muốn xóa bản sao lưu đám mây của sách đã chọn không?",
"What's New in Readest": "Những gì mới trong Readest",
"Enter book title": "Nhập tiêu đề sách",
@@ -457,5 +454,51 @@
"Storage": "Lưu trữ",
"Terms of Service": "Điều khoản dịch vụ",
"Privacy Policy": "Chính sách bảo mật",
"Disable Double Click": "Tắt Nhấp Đôi"
"Disable Double Click": "Tắt Nhấp Đôi",
"TTS not supported for this document": "TTS không được hỗ trợ cho tài liệu này",
"Reset Password": "Đặt lại mật khẩu",
"Show Reading Progress": "Hiển thị tiến độ đọc",
"Reading Progress Style": "Phong cách tiến độ đọc",
"Page Number": "Số trang",
"Percentage": "Phần trăm",
"Deleted local copy of the book: {{title}}": "Đã xóa bản sao cục bộ của sách: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Không thể xóa bản sao lưu đám mây của sách: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Không thể xóa bản sao cục bộ của sách: {{title}}",
"Are you sure to delete the local copy of the selected book?": "Bạn có chắc chắn muốn xóa bản sao cục bộ của cuốn sách đã chọn không?",
"Remove from Cloud & Device": "Xóa khỏi Đám mây & Thiết bị",
"Remove from Cloud Only": "Xóa chỉ khỏi Đám mây",
"Remove from Device Only": "Xóa chỉ khỏi Thiết bị",
"Disconnected": "Mất kết nối",
"KOReader Sync Settings": "Cài đặt đồng bộ KOReader",
"Sync Strategy": "Chiến lược đồng bộ",
"Ask on conflict": "Hỏi khi có xung đột",
"Always use latest": "Luôn sử dụng phiên bản mới nhất",
"Send changes only": "Chỉ gửi thay đổi",
"Receive changes only": "Chỉ nhận thay đổi",
"Disabled": "Đã tắt",
"Checksum Method": "Phương pháp kiểm tra",
"File Content (recommended)": "Nội dung tệp (được khuyến nghị)",
"File Name": "Tên tệp",
"Device Name": "Tên thiết bị",
"Sync Tolerance": "Độ dung sai đồng bộ",
"Connect to your KOReader Sync server.": "Kết nối với máy chủ đồng bộ KOReader của bạn.",
"Server URL": "URL máy chủ",
"Username": "Tên người dùng",
"Your Username": "Tên người dùng của bạn",
"Password": "Mật khẩu",
"Connect": "Kết nối",
"KOReader Sync": "Đồng bộ KOReader",
"Sync Conflict": "Xung đột đồng bộ",
"Sync reading progress from \"{{deviceName}}\"?": "Đồng bộ tiến độ đọc từ \"{{deviceName}}\"?",
"another device": "thiết bị khác",
"Local Progress": "Tiến độ cục bộ",
"Remote Progress": "Tiến độ từ xa",
"Page {{page}} of {{total}} ({{percentage}}%)": "Trang {{page}} của {{total}} ({{percentage}}%)",
"Current position": "Vị trí hiện tại",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Khoảng trang {{page}} của {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Khoảng {{percentage}}%",
"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}}"
}
@@ -95,7 +95,6 @@
"Speak": "朗读",
"Subjects": "主题",
"System Fonts": "系统字体",
"TTS not supported in this device": "此设备不支持 TTS",
"Theme Color": "主题颜色",
"Theme Mode": "主题模式",
"Translate": "翻译",
@@ -310,7 +309,6 @@
"Synced at {{time}}": "同步于:{{time}}",
"Never synced": "从未同步",
"Show Remaining Time": "显示剩余时间",
"Show Page Number": "显示页码",
"{{time}} min left in chapter": "本章剩余 {{time}} 分钟",
"Override Book Color": "覆盖书籍颜色",
"Login Required": "需登录",
@@ -407,7 +405,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "使用多达 5GB 的安全云存储保存和访问您的整个阅读收藏。",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "使用多达 20GB 的安全云存储保存和访问您的整个阅读收藏。",
"Deleted cloud backup of the book: {{title}}": "已删除书籍云备份:{{title}}",
"Failed to delete cloud backup of the book": "无法删除书籍云备份。",
"Are you sure to delete the cloud backup of the selected book?": "您确定要删除所选书籍的云备份吗?",
"What's New in Readest": "Readest 更新功能",
"Enter book title": "输入书名",
@@ -457,5 +454,51 @@
"Storage": "云同步空间",
"Terms of Service": "服务条款",
"Privacy Policy": "隐私政策",
"Disable Double Click": "禁用双击"
"Disable Double Click": "禁用双击",
"TTS not supported for this document": "TTS 不支持此文档",
"Reset Password": "重置密码",
"Show Reading Progress": "显示阅读进度",
"Reading Progress Style": "阅读进度样式",
"Page Number": "页码",
"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": "已断开连接",
"KOReader Sync Settings": "KOReader 同步设置",
"Sync Strategy": "同步策略",
"Ask on conflict": "发生冲突时询问",
"Always use latest": "始终使用最新",
"Send changes only": "仅发送更改",
"Receive changes only": "仅接收更改",
"Disabled": "已禁用",
"Checksum Method": "校验和方法",
"File Content (recommended)": "文件内容(推荐)",
"File Name": "文件名",
"Device Name": "设备名称",
"Sync Tolerance": "同步容忍度",
"Connect to your KOReader Sync server.": "连接到您的 KOReader 同步服务器。",
"Server URL": "服务器 URL",
"Username": "用户名",
"Your Username": "您的用户名",
"Password": "密码",
"Connect": "连接",
"KOReader Sync": "KOReader 同步",
"Sync Conflict": "同步冲突",
"Sync reading progress from \"{{deviceName}}\"?": "从 \"{{deviceName}}\" 同步阅读进度?",
"another device": "另一台设备",
"Local Progress": "本地进度",
"Remote Progress": "远程进度",
"Page {{page}} of {{total}} ({{percentage}}%)": "第 {{page}} 页,共 {{total}} 页 ({{percentage}}%)",
"Current position": "当前位置",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "大约第 {{page}} 页,共 {{total}} 页 ({{percentage}}%)",
"Approximately {{percentage}}%": "大约 {{percentage}}%",
"Failed to connect": "连接失败",
"Sync Server Connected": "同步服务器已连接",
"Precision: {{precision}} digits after the decimal": "精度:{{precision}} 位小数",
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步"
}
@@ -95,7 +95,6 @@
"Speak": "朗讀",
"Subjects": "主題",
"System Fonts": "系統字體",
"TTS not supported in this device": "此設備不支持 TTS",
"Theme Color": "主題顏色",
"Theme Mode": "主題模式",
"Translate": "翻譯",
@@ -310,7 +309,6 @@
"Synced at {{time}}": "同步於:{{time}}",
"Never synced": "從未同步",
"Show Remaining Time": "顯示剩餘時間",
"Show Page Number": "顯示頁碼",
"{{time}} min left in chapter": "本章剩餘 {{time}} 分鐘",
"Override Book Color": "覆蓋書籍顏色",
"Login Required": "需登入",
@@ -407,7 +405,6 @@
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "使用多達 5GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "使用多達 20GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
"Deleted cloud backup of the book: {{title}}": "已刪除書籍的雲端備份:{{title}}",
"Failed to delete cloud backup of the book": "無法刪除書籍的雲端備份。",
"Are you sure to delete the cloud backup of the selected book?": "您確定要刪除所選書籍的雲端備份嗎?",
"What's New in Readest": "Readest 更新功能",
"Enter book title": "輸入書名",
@@ -457,5 +454,51 @@
"Storage": "雲同步空間",
"Terms of Service": "服務條款",
"Privacy Policy": "隱私政策",
"Disable Double Click": "禁用雙擊"
"Disable Double Click": "禁用雙擊",
"TTS not supported for this document": "TTS 不支援此文件",
"Reset Password": "重設密碼",
"Show Reading Progress": "顯示閱讀進度",
"Reading Progress Style": "閱讀進度樣式",
"Page Number": "頁碼",
"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": "已斷開連接",
"KOReader Sync Settings": "KOReader 同步設置",
"Sync Strategy": "同步策略",
"Ask on conflict": "發生衝突時詢問",
"Always use latest": "始終使用最新",
"Send changes only": "僅發送更改",
"Receive changes only": "僅接收更改",
"Disabled": "已禁用",
"Checksum Method": "校驗和方法",
"File Content (recommended)": "文件內容(推薦)",
"File Name": "文件名",
"Device Name": "設備名稱",
"Sync Tolerance": "同步容忍度",
"Connect to your KOReader Sync server.": "連接到您的 KOReader 同步伺服器。",
"Server URL": "伺服器 URL",
"Username": "用戶名",
"Your Username": "您的用戶名",
"Password": "密碼",
"Connect": "連接",
"KOReader Sync": "KOReader 同步",
"Sync Conflict": "同步衝突",
"Sync reading progress from \"{{deviceName}}\"?": "從 \"{{deviceName}}\" 同步閱讀進度?",
"another device": "另一個設備",
"Local Progress": "本地進度",
"Remote Progress": "遠程進度",
"Page {{page}} of {{total}} ({{percentage}}%)": "第 {{page}} 頁,共 {{total}} 頁 ({{percentage}}%)",
"Current position": "當前位置",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "大約第 {{page}} 頁,共 {{total}} 頁 ({{percentage}}%)",
"Approximately {{percentage}}%": "大約 {{percentage}}%",
"Failed to connect": "連接失敗",
"Sync Server Connected": "同步伺服器已連接",
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 位小數",
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步"
}
+14
View File
@@ -1,5 +1,19 @@
{
"releases": {
"0.9.71": {
"date": "2025-08-13",
"notes": [
"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",
"TTS: Improved book language detection when language code is invalid",
"iOS: Fixed applying system color scheme in auto theme mode",
"Config: Added option to choose reading progress display style (percentage or page number)",
"Library: Added option to delete only the local copy of a book",
"Account: Added 'Reset Password' button in the account page"
]
},
"0.9.69": {
"date": "2025-08-02",
"notes": [
@@ -13,6 +13,7 @@ const COMMANDS: &[&str] = &[
"iap_fetch_products",
"iap_purchase_product",
"iap_restore_purchases",
"get_system_color_scheme",
];
fn main() {
@@ -533,6 +533,12 @@ class NativeBridgePlugin: Plugin {
invoke.resolve(["purchases": restored])
}
}
@objc public func get_system_color_scheme(_ invoke: Invoke) {
let userInterfaceStyle = UITraitCollection.current.userInterfaceStyle
let colorScheme = (userInterfaceStyle == .dark) ? "dark" : "light"
invoke.resolve(["colorScheme": colorScheme])
}
}
@_cdecl("init_plugin_native_bridge")
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-system-color-scheme"
description = "Enables the get_system_color_scheme command without any pre-configured scope."
commands.allow = ["get_system_color_scheme"]
[[permission]]
identifier = "deny-get-system-color-scheme"
description = "Denies the get_system_color_scheme command without any pre-configured scope."
commands.deny = ["get_system_color_scheme"]
@@ -18,6 +18,7 @@ Default permissions for the plugin
- `allow-iap-fetch-products`
- `allow-iap-purchase-product`
- `allow-iap-restore-purchases`
- `allow-get-system-color-scheme`
## Permission Table
@@ -161,6 +162,32 @@ Denies the get_sys_fonts_list command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-get-system-color-scheme`
</td>
<td>
Enables the get_system_color_scheme command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-system-color-scheme`
</td>
<td>
Denies the get_system_color_scheme command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-iap-fetch-products`
</td>
@@ -15,4 +15,5 @@ permissions = [
"allow-iap-fetch-products",
"allow-iap-purchase-product",
"allow-iap-restore-purchases",
"allow-get-system-color-scheme",
]
@@ -354,6 +354,18 @@
"const": "deny-get-sys-fonts-list",
"markdownDescription": "Denies the get_sys_fonts_list command without any pre-configured scope."
},
{
"description": "Enables the get_system_color_scheme command without any pre-configured scope.",
"type": "string",
"const": "allow-get-system-color-scheme",
"markdownDescription": "Enables the get_system_color_scheme command without any pre-configured scope."
},
{
"description": "Denies the get_system_color_scheme command without any pre-configured scope.",
"type": "string",
"const": "deny-get-system-color-scheme",
"markdownDescription": "Denies the get_system_color_scheme command without any pre-configured scope."
},
{
"description": "Enables the iap_fetch_products command without any pre-configured scope.",
"type": "string",
@@ -463,10 +475,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`"
}
]
}
@@ -112,3 +112,10 @@ pub(crate) async fn iap_restore_purchases<R: Runtime>(
) -> Result<IAPRestorePurchasesResponse> {
app.native_bridge().iap_restore_purchases()
}
#[command]
pub(crate) async fn get_system_color_scheme<R: Runtime>(
app: AppHandle<R>,
) -> Result<GetSystemColorSchemeResponse> {
app.native_bridge().get_system_color_scheme()
}
@@ -98,4 +98,8 @@ impl<R: Runtime> NativeBridge<R> {
pub fn iap_restore_purchases(&self) -> crate::Result<IAPRestorePurchasesResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_system_color_scheme(&self) -> crate::Result<GetSystemColorSchemeResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -51,6 +51,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::iap_fetch_products,
commands::iap_purchase_product,
commands::iap_restore_purchases,
commands::get_system_color_scheme,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -147,11 +147,17 @@ impl<R: Runtime> NativeBridge<R> {
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_restore_purchases(
&self,
) -> crate::Result<IAPRestorePurchasesResponse> {
pub fn iap_restore_purchases(&self) -> crate::Result<IAPRestorePurchasesResponse> {
self.0
.run_mobile_plugin("iap_restore_purchases", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_system_color_scheme(&self) -> crate::Result<GetSystemColorSchemeResponse> {
self.0
.run_mobile_plugin("get_system_color_scheme", ())
.map_err(Into::into)
}
}
@@ -107,7 +107,7 @@ pub struct Purchase {
pub purchase_date: String,
pub original_transaction_id: String,
pub purchase_state: String, // "purchased", "pending", "cancelled"
pub platform: String, // "ios" or "android"
pub platform: String, // "ios" or "android"
}
#[derive(Debug, Serialize, Deserialize)]
@@ -152,3 +152,9 @@ pub struct IAPPurchaseProductResponse {
pub struct IAPRestorePurchasesResponse {
pub purchases: Vec<Purchase>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSystemColorSchemeResponse {
pub color_scheme: String, // "light" or "dark"
}
@@ -0,0 +1,385 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { XCFI } from '@/utils/xcfi';
describe('CFIToXPointerConverter', () => {
let converter: XCFI;
let simpleDoc: Document;
let complexDoc: Document;
beforeEach(() => {
simpleDoc = new DOMParser().parseFromString(
`
<html>
<head>
<title>Simple Document</title>
</head>
<body>
<div>
<p>First paragraph</p>
<p>Second paragraph with some text</p>
<p>Third paragraph</p>
</div>
</body>
</html>
`,
'text/html',
);
complexDoc = new DOMParser().parseFromString(
`
<html>
<head>
<title>Complex Document</title>
</head>
<body>
<section>
<h1>Chapter 1</h1>
<p>First paragraph</p>
<p>Second paragraph</p>
</section>
<section>
<h1>Chapter 2</h1>
<p id="special">Another paragraph</p>
<p>Final paragraph with <em>emphasis</em> and more text</p>
</section>
</body>
</html>
`,
'text/html',
);
});
describe('static methods', () => {
it('should extract spine index from CFI', () => {
const cfi1 = 'epubcfi(/6/2!/4/2/4)'; // Spine index 0
const cfi2 = 'epubcfi(/6/4!/4/2/4)'; // Spine index 1
const cfi3 = 'epubcfi(/6/10!/4/2/4)'; // Spine index 4
expect(XCFI.extractSpineIndex(cfi1)).toBe(0);
expect(XCFI.extractSpineIndex(cfi2)).toBe(1);
expect(XCFI.extractSpineIndex(cfi3)).toBe(4);
});
it('should extract spine index from range CFI', () => {
const rangeCfi = 'epubcfi(/6/8!/4/2/2/1:5,/6/8!/4/2/4/1:10)'; // Spine index 3
expect(XCFI.extractSpineIndex(rangeCfi)).toBe(3);
});
it('should extract spine index from CFI with assertions', () => {
const cfi = 'epubcfi(/6/1266!/4,/76,/88/1:85)'; // Complex CFI, spine index 632
expect(XCFI.extractSpineIndex(cfi)).toBe(632);
});
it('should throw error for invalid CFI in extractSpineIndex', () => {
const invalidCfi = 'invalid-cfi';
expect(() => XCFI.extractSpineIndex(invalidCfi)).toThrow('Cannot extract spine index');
});
});
describe('round-trip conversion - point CFI', () => {
beforeEach(() => {
converter = new XCFI(simpleDoc, 1);
});
it('should convert first element CFI round-trip', () => {
const originalCfi = 'epubcfi(/6/4!/4/2/2)'; // First p element
const xpointer = converter.cfiToXPointer(originalCfi);
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer).toEqual({
xpointer: '/body/DocFragment[2]/body/div/p[0]',
});
});
it('should convert basic element CFI round-trip', () => {
const originalCfi = 'epubcfi(/6/4!/4/2/4)'; // Second p element
const xpointer = converter.cfiToXPointer(originalCfi);
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer).toEqual({
xpointer: '/body/DocFragment[2]/body/div/p[1]',
});
});
it('should convert third element CFI round-trip', () => {
const originalCfi = 'epubcfi(/6/4!/4/2/6)'; // Third p element
const xpointer = converter.cfiToXPointer(originalCfi);
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer).toEqual({
xpointer: '/body/DocFragment[2]/body/div/p[2]',
});
});
});
describe('round-trip conversion - range CFI', () => {
beforeEach(() => {
converter = new XCFI(simpleDoc, 2);
});
it('should convert standard range CFI', () => {
const originalCfi = 'epubcfi(/6/6!/4/2,/2/1:6,/4/1:16)'; // From first p:6 to second p:16
const xpointer = converter.cfiToXPointer(originalCfi);
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toEqual('/body/DocFragment[3]/body/div/p[0]/text().6');
expect(xpointer.pos0).toEqual('/body/DocFragment[3]/body/div/p[0]/text().6');
expect(xpointer.pos1).toEqual('/body/DocFragment[3]/body/div/p[1]/text().16');
});
it('should convert range CFI within same element', () => {
const originalCfi = 'epubcfi(/6/6!/4/2/4,/1:5,/1:10)'; // Within second p element
const xpointer = converter.cfiToXPointer(originalCfi);
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.pos0).toMatch(/\/text\(\)\.5$/);
expect(xpointer.pos1).toMatch(/\/text\(\)\.10$/);
});
it('should handle range across multiple elements', () => {
const originalCfi = 'epubcfi(/6/6!/4/2,/2,/6)'; // From first to third p
const xpointer = converter.cfiToXPointer(originalCfi);
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.pos0).toMatch(/p\[0\]/);
expect(xpointer.pos1).toMatch(/p\[2\]/);
});
});
describe('round-trip conversion - complex document', () => {
beforeEach(() => {
converter = new XCFI(complexDoc, 3);
});
it('should handle nested elements', () => {
const originalCfi = 'epubcfi(/6/8!/4/2/2)'; // First section
const xpointer = converter.cfiToXPointer(originalCfi);
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toMatch(/\/body\/section\[0\]/);
});
it('should handle elements with IDs', () => {
const originalCfi = 'epubcfi(/6/8!/4/4/4[special])'; // Element with id="special"
const xpointer = converter.cfiToXPointer(originalCfi);
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[0\]/);
});
it('should handle inline elements', () => {
const originalCfi = 'epubcfi(/6/8!/4/4/6)'; // Text with inline em element
const xpointer = converter.cfiToXPointer(originalCfi);
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[1\]/);
});
});
describe('convertCFI - error handling', () => {
beforeEach(() => {
converter = new XCFI(simpleDoc, 0);
});
it('should throw error for invalid CFI format', () => {
const invalidCfi = 'invalid-cfi';
expect(() => converter.cfiToXPointer(invalidCfi)).toThrow('Failed to convert CFI');
});
it('should throw error for CFI with invalid path', () => {
const invalidCfi = 'epubcfi(/6/999!/2/2)'; // Non-existent path
expect(() => converter.cfiToXPointer(invalidCfi)).toThrow();
});
it('should handle malformed CFI gracefully', () => {
const malformedCfi = 'epubcfi(/6/2/2';
expect(() => converter.cfiToXPointer(malformedCfi)).toThrow();
});
});
describe('xPointerToCFI - direct XPointer input', () => {
beforeEach(() => {
converter = new XCFI(simpleDoc, 1);
});
it('should convert XPointer to CFI for first element', () => {
const xpointer = '/body/DocFragment[2]/body/div/p[0]';
const cfi = converter.xPointerToCFI(xpointer);
// Verify by converting back to XPointer
const backToXPointer = converter.cfiToXPointer(cfi);
expect(backToXPointer.xpointer).toBe(xpointer);
});
it('should convert XPointer to CFI for second element', () => {
const xpointer = '/body/DocFragment[2]/body/div/p[1]';
const cfi = converter.xPointerToCFI(xpointer);
const backToXPointer = converter.cfiToXPointer(cfi);
expect(backToXPointer.xpointer).toBe(xpointer);
});
it('should convert XPointer with text offset to CFI', () => {
const xpointer = '/body/DocFragment[2]/body/div[0]/p[0]/text().6';
const cfi = converter.xPointerToCFI(xpointer);
expect(cfi).toBe('epubcfi(/6/4!/4/2/2/1:6)');
});
it('should convert range XPointer to CFI', () => {
const pos0 = '/body/DocFragment[2]/body/div/p[0]/text().6';
const pos1 = '/body/DocFragment[2]/body/div/p[1]/text().16';
const cfi = converter.xPointerToCFI(pos0, pos1);
const xpointer = converter.cfiToXPointer(cfi);
expect(cfi).toMatch(/^epubcfi\([^,]+,[^,]+,[^,]+\)$/);
expect(xpointer.pos0).toBe(pos0);
expect(xpointer.pos1).toBe(pos1);
});
});
describe('xPointerToCFI - error handling', () => {
beforeEach(() => {
converter = new XCFI(simpleDoc, 0);
});
it('should throw error for invalid XPointer format', () => {
const invalidXPointer = 'invalid-xpointer';
expect(() => converter.xPointerToCFI(invalidXPointer)).toThrow('Failed to convert XPointer');
});
it('should throw error for XPointer with non-existent path', () => {
const invalidXPointer = '/body/DocFragment[1]/body/nonexistent[999]';
expect(() => converter.xPointerToCFI(invalidXPointer)).toThrow();
});
it('should throw error for malformed XPointer', () => {
const malformedXPointer = '/body/DocFragment[1]/body/div[';
expect(() => converter.xPointerToCFI(malformedXPointer)).toThrow();
});
it('should handle CFI without spine step prefix', () => {
// Test the adjustSpineIndex method handles CFIs that don't start with /6/n!
const converter = new XCFI(simpleDoc, 3); // Use different spine index
const xpointer = '/body/DocFragment[4]/body/div/p[0]';
const cfi = converter.xPointerToCFI(xpointer);
// Verify the spine step is correctly added/adjusted
expect(cfi).toMatch(/^epubcfi\(\/6\/8!/); // (3+1)*2 = 8
// Verify round-trip works
const backToXPointer = converter.cfiToXPointer(cfi);
expect(backToXPointer.xpointer).toBe(xpointer);
});
});
describe('validateCFI', () => {
beforeEach(() => {
converter = new XCFI(simpleDoc, 0);
});
it('should validate correct CFI', () => {
const validCfi = 'epubcfi(/6/2!/4/4)';
expect(converter.validateCFI(validCfi)).toBe(true);
});
it('should invalidate incorrect CFI format', () => {
const invalidCfi = 'invalid-cfi';
expect(converter.validateCFI(invalidCfi)).toBe(false);
});
it('should invalidate CFI with wrong path', () => {
const invalidCfi = 'epubcfi(/6/2!/998/2/2)';
expect(converter.validateCFI(invalidCfi)).toBe(false);
});
it('should validate range CFI', () => {
const validRangeCfi = 'epubcfi(/6/2!/4/2,/2/1:5,/4/1:10)';
expect(converter.validateCFI(validRangeCfi)).toBe(true);
});
});
describe('edge cases', () => {
beforeEach(() => {
converter = new XCFI(simpleDoc, 0);
});
it('should handle empty elements', () => {
const emptyDoc = new DOMParser().parseFromString(
`
<html>
<body>
<div>
<p></p>
<p>Non-empty</p>
</div>
</body>
</html>
`,
'text/html',
);
const converter = new XCFI(emptyDoc, 2);
const cfi = 'epubcfi(/6/6!/4/2/2)'; // Empty p element
const result = converter.cfiToXPointer(cfi);
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[0]');
});
it('should handle whitespace-only text nodes', () => {
const whitespaceDoc = new DOMParser().parseFromString(
`
<html>
<body>
<div>
<p> </p>
<p>Real content</p>
</div>
</body>
</html>
`,
'text/html',
);
const converter = new XCFI(whitespaceDoc, 2);
const cfi = 'epubcfi(/6/6!/4/2/4)'; // Second p element
const result = converter.cfiToXPointer(cfi);
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[1]');
});
it('should handle deeply nested elements', () => {
const nestedDoc = new DOMParser().parseFromString(
`
<html>
<body>
<div>
<section>
<article>
<p>Deeply nested p0</p>
<p>Deeply nested p1</p>
</article>
</section>
</div>
</body>
</html>
`,
'text/html',
);
const converter = new XCFI(nestedDoc, 2);
const cfi = 'epubcfi(/6/6!/4/2/2/2/2)'; // Deeply nested p
const result = converter.cfiToXPointer(cfi);
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/section/article/p[0]');
});
});
});
+12 -2
View File
@@ -24,6 +24,7 @@ import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oau
import { openUrl } from '@tauri-apps/plugin-opener';
import { invoke } from '@tauri-apps/api/core';
import { handleAuthCallback } from '@/helpers/auth';
import { getUserPlan } from '@/utils/access';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
import WindowButtons from '@/components/WindowButtons';
@@ -170,8 +171,11 @@ export default function AuthPage() {
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const type = params.get('type');
const next = params.get('next') ?? '/';
if (accessToken) {
let next = params.get('next') ?? '/';
if (getUserPlan(accessToken) === 'free') {
next = '/user';
}
handleAuthCallback({ accessToken, refreshToken, type, next, login, navigate: router.push });
}
}
@@ -310,7 +314,13 @@ export default function AuthPage() {
if (session?.access_token && session.user) {
login(session.access_token, session.user);
const redirectTo = new URLSearchParams(window.location.search).get('redirect');
router.push(redirectTo ?? '/library');
const lastRedirectAtKey = 'lastRedirectAt';
const lastRedirectAt = parseInt(localStorage.getItem(lastRedirectAtKey) || '0', 10);
const now = Date.now();
localStorage.setItem(lastRedirectAtKey, now.toString());
if (now - lastRedirectAt > 3000) {
router.push(redirectTo ?? '/library');
}
}
});
+4 -1
View File
@@ -1,4 +1,5 @@
import * as React from 'react';
import { EnvProvider } from '@/context/EnvContext';
import Providers from '@/components/Providers';
import '../styles/globals.css';
@@ -69,7 +70,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<meta name='twitter:image' content={previewImage} />
</head>
<body>
<Providers>{children}</Providers>
<EnvProvider>
<Providers>{children}</Providers>
</EnvProvider>
</body>
</html>
);
@@ -0,0 +1,381 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { md5 } from 'js-md5';
import clsx from 'clsx';
import { type as osType } from '@tauri-apps/plugin-os';
import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useEnv } from '@/context/EnvContext';
import { eventDispatcher } from '@/utils/event';
import { KOSyncClient } from '@/services/sync/KOSyncClient';
import { KoreaderSyncChecksumMethod, KoreaderSyncStrategy } from '@/types/settings';
import { v4 as uuidv4 } from 'uuid';
import { debounce } from '@/utils/debounce';
import { getOSPlatform } from '@/utils/misc';
type Option = {
value: string;
label: string;
};
type SelectProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: Option[];
disabled?: boolean;
className?: string;
};
const StyledSelect: React.FC<SelectProps> = ({
value,
onChange,
options,
className,
disabled = false,
}) => {
return (
<select
value={value}
onChange={onChange}
className={clsx(
'select select-bordered h-12 w-full text-sm focus:outline-none focus:ring-0',
className,
)}
disabled={disabled}
>
{options.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
);
};
export const setKOSyncSettingsWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('kosync_settings_window');
if (dialog) {
const event = new CustomEvent('setKOSyncSettingsVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const KOSyncSettingsWindow: React.FC = () => {
const _ = useTranslation();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { envConfig, appService } = useEnv();
const [isOpen, setIsOpen] = useState(false);
const [url, setUrl] = useState(settings.koreaderSyncServerUrl || '');
const [username, setUsername] = useState(settings.koreaderSyncUsername || '');
const [password, setPassword] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('');
const [deviceName, setDeviceName] = useState('');
const [osName, setOsName] = useState('');
const [toleranceSliderValue, setToleranceSliderValue] = useState(() => {
const tolerance = settings.koreaderSyncPercentageTolerance;
return tolerance && tolerance > 0 ? Math.round(-Math.log10(tolerance)) : 4;
});
// Get the OS name once
useEffect(() => {
const getOsName = async () => {
let name = '';
if (appService?.appPlatform === 'tauri') {
name = await osType();
} else {
const platform = getOSPlatform();
if (platform !== 'unknown') {
name = platform;
}
}
setOsName(name ? name.charAt(0).toUpperCase() + name.slice(1) : '');
};
getOsName();
}, [appService]);
useEffect(() => {
const defaultName = osName ? `Readest (${osName})` : 'Readest';
setDeviceName(settings.koreaderSyncDeviceName || defaultName);
}, [settings.koreaderSyncDeviceName, osName]);
const isConfigured = useMemo(
() => !!settings.koreaderSyncUserkey,
[settings.koreaderSyncUserkey],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSaveDeviceName = useCallback(
debounce((newDeviceName: string) => {
const newSettings = { ...settings, koreaderSyncDeviceName: newDeviceName };
setSettings(newSettings);
saveSettings(envConfig, newSettings);
}, 500),
[settings, setSettings, saveSettings, envConfig],
);
const handleDeviceNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value;
setDeviceName(newName);
debouncedSaveDeviceName(newName);
};
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
setUrl(settings.koreaderSyncServerUrl || '');
setUsername(settings.koreaderSyncUsername || '');
setPassword('');
setConnectionStatus('');
// Sync the slider with the current settings when opening
const tolerance = settings.koreaderSyncPercentageTolerance;
setToleranceSliderValue(
tolerance && tolerance > 0 ? Math.round(-Math.log10(tolerance)) : 4,
);
}
};
const el = document.getElementById('kosync_settings_window');
el?.addEventListener('setKOSyncSettingsVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setKOSyncSettingsVisibility', handleCustomEvent as EventListener);
};
}, [
settings.koreaderSyncServerUrl,
settings.koreaderSyncUsername,
settings.koreaderSyncPercentageTolerance,
]);
const handleConnect = async () => {
setIsConnecting(true);
let deviceId = settings.koreaderSyncDeviceId;
if (!deviceId) {
deviceId = uuidv4().replace(/-/g, '').toUpperCase();
}
const client = new KOSyncClient(
url,
username,
md5(password),
settings.koreaderSyncChecksumMethod,
deviceId,
deviceName,
);
const result = await client.connect(username, password);
if (result.success) {
const newSettings = {
...settings,
koreaderSyncServerUrl: url,
koreaderSyncUsername: username,
koreaderSyncUserkey: md5(password),
koreaderSyncDeviceId: deviceId,
koreaderSyncDeviceName: deviceName,
koreaderSyncStrategy:
settings.koreaderSyncStrategy === 'disabled' ? 'prompt' : settings.koreaderSyncStrategy,
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
} else {
setConnectionStatus('');
eventDispatcher.dispatch('toast', {
message: `${_('Failed to connect')}: ${_(result.message || 'Connection error')}`,
type: 'error',
});
}
setIsConnecting(false);
setPassword('');
};
const handleDisconnect = async () => {
const newSettings = {
...settings,
koreaderSyncStrategy: 'disabled' as KoreaderSyncStrategy,
koreaderSyncUserkey: '',
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
setUsername('');
eventDispatcher.dispatch('toast', { message: _('Disconnected'), type: 'info' });
};
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newStrategy = e.target.value as KoreaderSyncStrategy;
const newSettings = { ...settings, koreaderSyncStrategy: newStrategy };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const handleChecksumMethodChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newMethod = e.target.value as KoreaderSyncChecksumMethod;
const newSettings = { ...settings, koreaderSyncChecksumMethod: newMethod };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const handleToleranceChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const sliderValue = parseInt(e.target.value, 10);
setToleranceSliderValue(sliderValue);
// Calculate the actual tolerance from the slider value (e.g., 4 -> 0.0001)
const newTolerance = Math.pow(10, -sliderValue);
const newSettings = { ...settings, koreaderSyncPercentageTolerance: newTolerance };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
return (
<Dialog
id='kosync_settings_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('KOReader Sync Settings')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
bgClassName='!bg-black/60'
>
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
<>
<div className='text-center'>
<p className='text-base-content/80 text-sm'>
{_('Sync as {{userDisplayName}}', {
userDisplayName: settings.koreaderSyncUsername,
})}
</p>
</div>
<div className='flex h-14 items-center justify-between'>
<span className='text-base-content/80'>
{_('Sync Server Connected', { username: settings.koreaderSyncUsername })}
</span>
<input
type='checkbox'
className='toggle'
checked={settings.koreaderSyncStrategy !== 'disabled'}
onChange={() => handleDisconnect()}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Sync Strategy')}</span>
</label>
<StyledSelect
value={settings.koreaderSyncStrategy}
onChange={handleStrategyChange}
options={[
{ value: 'prompt', label: _('Ask on conflict') },
{ value: 'silent', label: _('Always use latest') },
{ value: 'send', label: _('Send changes only') },
{ value: 'receive', label: _('Receive changes only') },
{ value: 'disable', label: _('Disabled') },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Checksum Method')}</span>
</label>
<StyledSelect
value={settings.koreaderSyncChecksumMethod}
onChange={handleChecksumMethodChange}
options={[
{ value: 'binary', label: _('File Content (recommended)') },
{ value: 'filename', label: _('File Name') },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Device Name')}</span>
</label>
<input
type='text'
placeholder={osName ? `Readest (${osName})` : 'Readest'}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={deviceName}
onChange={handleDeviceNameChange}
/>
</div>
{/* Hidden to avoid confusing users with technical details */}
{false && (
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Sync Tolerance')}</span>
</label>
<input
type='range'
min='0'
max='15'
value={toleranceSliderValue}
onChange={handleToleranceChange}
className='range range-primary'
/>
<div className='text-base-content/70 mt-2 text-center text-xs'>
{_('Precision: {{precision}} digits after the decimal', {
precision: toleranceSliderValue,
})}
</div>
</div>
)}
</>
) : (
<>
<p className='text-base-content/70 text-center text-sm'>
{_('Connect to your KOReader Sync server.')}
</p>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Server URL')}</span>
</label>
<input
type='text'
placeholder='https://koreader.sync.server'
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
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>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
disabled={isConnecting || !url || !username || !password}
>
{isConnecting ? <span className='loading loading-spinner'></span> : _('Connect')}
</button>
{connectionStatus && (
<div className='text-error h-4 text-center text-sm'>{connectionStatus}</div>
)}
</>
)}
</div>
</Dialog>
);
};
@@ -8,6 +8,7 @@ import { TbSunMoon } from 'react-icons/tb';
import { BiMoon, BiSun } from 'react-icons/bi';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { setKOSyncSettingsWindowVisible } from './KOSyncSettings';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
@@ -158,6 +159,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsTelemetryEnabled(settings.telemetryEnabled);
};
const showKoSyncSettingsWindow = () => {
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handleUpgrade = () => {
navigateToProfile(router);
setIsDropdownOpen?.(false);
@@ -268,6 +274,8 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={cycleThemeMode}
/>
<hr className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
<hr className='border-base-200 my-1' />
{user && userPlan === 'free' && !appService?.isIOSApp && (
<MenuItem label={_('Upgrade to Readest Premium')} onClick={handleUpgrade} />
)}
+37 -51
View File
@@ -8,7 +8,7 @@ import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overl
import 'overlayscrollbars/overlayscrollbars.css';
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { AppService, DeleteAction } from '@/types/system';
import { navigateToLogin, navigateToReader } from '@/utils/nav';
import {
formatAuthors,
@@ -48,6 +48,7 @@ import {
} from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { KOSyncSettingsWindow } from './components/KOSyncSettings';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { BookMetadata } from '@/libs/document';
import { BookDetailModal } from '@/components/metadata';
@@ -543,53 +544,36 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
[appService],
);
const handleBookDelete = async (book: Book) => {
try {
await appService?.deleteBook(book, !!book.uploadedAt, true);
await updateBook(envConfig, book);
pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book deleted: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to delete book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
};
const handleBookDeleteCloudBackup = async (book: Book) => {
try {
await appService?.deleteBook(book, !!book.uploadedAt, false);
await updateBook(envConfig, book);
pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Deleted cloud backup of the book: {{title}}', {
title: book.title,
}),
});
return true;
} catch (e) {
console.error(e);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to delete cloud backup of the book', {
title: book.title,
}),
});
return false;
}
const handleBookDelete = (deleteAction: DeleteAction) => {
return async (book: Book) => {
const deletionMessages = {
both: _('Book deleted: {{title}}', { title: book.title }),
cloud: _('Deleted cloud backup of the book: {{title}}', { title: book.title }),
local: _('Deleted local copy of the book: {{title}}', { title: book.title }),
};
const deletionFailMessages = {
both: _('Failed to delete book: {{title}}', { title: book.title }),
cloud: _('Failed to delete cloud backup of the book: {{title}}', { title: book.title }),
local: _('Failed to delete local copy of the book: {{title}}', { title: book.title }),
};
try {
await appService?.deleteBook(book, deleteAction);
await updateBook(envConfig, book);
pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: deletionMessages[deleteAction],
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: deletionFailMessages[deleteAction],
type: 'error',
});
return false;
}
};
};
const handleUpdateMetadata = async (book: Book, metadata: BookMetadata) => {
@@ -731,7 +715,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleImportBooks={handleImportBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleBookDelete={handleBookDelete('both')}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
booksTransferProgress={booksTransferProgress}
@@ -763,12 +747,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
onClose={() => setShowDetailsBook(null)}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleBookDeleteCloudBackup={handleBookDeleteCloudBackup}
handleBookDelete={handleBookDelete('both')}
handleBookDeleteCloudBackup={handleBookDelete('cloud')}
handleBookDeleteLocalCopy={handleBookDelete('local')}
handleBookMetadataUpdate={handleUpdateMetadata}
/>
)}
<AboutWindow />
<KOSyncSettingsWindow />
<UpdaterWindow />
<Toast />
</div>
@@ -0,0 +1,49 @@
import React from 'react';
import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { SyncDetails } from '../hooks/useKOSync';
interface ConfirmSyncDialogProps {
details: SyncDetails | null;
onConfirmLocal: () => void;
onConfirmRemote: () => void;
onClose: () => void;
}
const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
details,
onConfirmLocal,
onConfirmRemote,
onClose,
}) => {
const _ = useTranslation();
if (!details) return null;
return (
<Dialog isOpen={true} onClose={onClose} title={_('Sync Conflict')}>
<p className='py-4 text-center'>
{_('Sync reading progress from "{{deviceName}}"?', {
deviceName: details.remote.device || _('another device'),
})}
</p>
<div className='mt-4 space-y-4'>
<button className='btn h-auto w-full flex-col items-start py-2' onClick={onConfirmLocal}>
<span>{_('Local Progress')}</span>
<span className='text-xs font-normal normal-case text-gray-500'>
{details.local.preview}
</span>
</button>
<button
className='btn btn-primary h-auto w-full flex-col items-start py-2'
onClick={onConfirmRemote}
>
<span>{_('Remote Progress')}</span>
<span className='text-xs font-normal normal-case'>{details.remote.preview}</span>
</button>
</div>
</Dialog>
);
};
export default ConfirmSyncDialog;
@@ -13,6 +13,7 @@ import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useKOSync } from '../hooks/useKOSync';
import {
applyFixedlayoutStyles,
applyImageStyle,
@@ -42,6 +43,7 @@ import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
import ConfirmSyncDialog from './ConfirmSyncDialog';
declare global {
interface Window {
@@ -77,6 +79,12 @@ const FoliateViewer: React.FC<{
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
const {
syncState,
conflictDetails,
resolveConflictWithLocal,
resolveConflictWithRemote,
} = useKOSync(bookKey);
useTextTranslation(bookKey, viewRef.current);
const progressRelocateHandler = (event: Event) => {
@@ -105,7 +113,7 @@ const FoliateViewer: React.FC<{
bookKey,
viewSettings,
content: data,
transformers: ['punctuation', 'footnote'],
transformers: ['punctuation', 'footnote', 'language'],
};
return Promise.resolve(transformContent(ctx));
}
@@ -349,12 +357,22 @@ const FoliateViewer: React.FC<{
]);
return (
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
<>
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
{syncState === 'conflict' && conflictDetails && (
<ConfirmSyncDialog
details={conflictDetails}
onConfirmLocal={resolveConflictWithLocal}
onConfirmRemote={resolveConflictWithRemote}
onClose={resolveConflictWithLocal}
/>
)}
</>
);
};
@@ -1,10 +1,11 @@
import clsx from 'clsx';
import React from 'react';
import { Insets } from '@/types/misc';
import { PageInfo, TimeInfo } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { PageInfo, TimeInfo } from '@/types/book';
import { formatReadingProgress } from '@/utils/progress';
interface PageInfoProps {
bookKey: string;
@@ -36,18 +37,19 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
const isScrolled = viewSettings.scrolled;
const isVertical = viewSettings.vertical;
const pageInfo = ['PDF', 'CBZ'].includes(bookFormat)
? section
const { progressStyle: readingProgressStyle } = viewSettings;
const formatTemplate =
readingProgressStyle === 'fraction'
? isVertical
? `${section.current + 1} · ${section.total}`
: `${section.current + 1} / ${section.total}`
: ''
: pageinfo && pageinfo.current >= 0 && pageinfo.total > 0
? _(isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}', {
currentPage: pageinfo.current + 1,
totalPage: pageinfo.total,
})
: '';
? '{current} · {total}'
: '{current} / {total}'
: '{percent}%';
const progressInfo = ['PDF', 'CBZ'].includes(bookFormat)
? formatReadingProgress(section?.current, section?.total, formatTemplate)
: formatReadingProgress(pageinfo?.current, pageinfo?.total, formatTemplate);
const timeLeft = timeinfo
? _('{{time}} min left in chapter', { time: Math.round(timeinfo.section) })
: '';
@@ -85,7 +87,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showPageNumber && <span className='ms-auto text-end'>{pageInfo}</span>}
{viewSettings.showProgressInfo && <span className='ms-auto text-end'>{progressInfo}</span>}
</div>
);
};
@@ -1,8 +1,7 @@
'use client';
import clsx from 'clsx';
import * as React from 'react';
import { useState, useRef, useEffect } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Book } from '@/types/book';
@@ -75,6 +74,10 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
return true;
};
eventDispatcher.onSync('show-book-details', handleShowBookDetails);
return () => {
eventDispatcher.offSync('show-book-details', handleShowBookDetails);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -106,8 +109,9 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { book } = getBookData(bookKey) || {};
const { isPrimary } = getViewState(bookKey) || {};
if (isPrimary && book && config) {
eventDispatcher.dispatch('sync-book-progress', { bookKey });
const settings = useSettingsStore.getState().settings;
eventDispatcher.dispatch('sync-book-progress', { bookKey });
eventDispatcher.dispatch('flush-koreader-sync', { bookKey });
await saveConfig(envConfig, bookKey, config, settings);
}
};
@@ -5,7 +5,7 @@ import { useAuth } from '@/context/AuthContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTranslator } from '@/hooks/useTranslator';
import { TRANSLATED_LANGS } from '@/services/constants';
import { TRANSLATOR_LANGS } from '@/services/constants';
import { UseTranslatorOptions, getTranslators } from '@/services/translators';
import Select from '@/components/Select';
@@ -13,11 +13,11 @@ const notSupportedLangs = [''];
const generateTranslatorLangs = () => {
return Object.fromEntries(
Object.entries(TRANSLATED_LANGS).filter(([code]) => !notSupportedLangs.includes(code)),
Object.entries(TRANSLATOR_LANGS).filter(([code]) => !notSupportedLangs.includes(code)),
);
};
const TRANSLATOR_LANGS = generateTranslatorLangs();
const translatorLangs = generateTranslatorLangs();
interface TranslatorPopupProps {
text: string;
@@ -47,9 +47,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
const [provider, setProvider] = useState(settings.globalReadSettings.translationProvider);
const [translation, setTranslation] = useState<string | null>(null);
const [detectedSourceLang, setDetectedSourceLang] = useState<
keyof typeof TRANSLATOR_LANGS | null
>(null);
const [detectedSourceLang, setDetectedSourceLang] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -153,12 +151,12 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
onChange={handleSourceLangChange}
options={[
{ value: 'AUTO', label: _('Auto Detect') },
...Object.entries(TRANSLATOR_LANGS)
...Object.entries(translatorLangs)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => {
const label =
detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
? `${TRANSLATOR_LANGS[detectedSourceLang] || detectedSourceLang} ` +
? `${translatorLangs[detectedSourceLang] || detectedSourceLang} ` +
_('(detected)')
: name;
return { value: code, label };
@@ -178,7 +176,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
className='bg-gray-600 text-white/75'
value={targetLang}
onChange={handleTargetLangChange}
options={Object.entries(TRANSLATOR_LANGS)
options={Object.entries(translatorLangs)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => ({ value: code, label: name }))}
/>
@@ -7,7 +7,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { getTranslators } from '@/services/translators';
import { TRANSLATED_LANGS } from '@/services/constants';
import { TRANSLATED_LANGS, TRANSLATOR_LANGS } from '@/services/constants';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { useResetViewSettings } from '../../hooks/useResetSettings';
import { saveAndReload } from '@/utils/reload';
@@ -54,8 +54,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
};
};
const getLangOptions = () => {
const langs = TRANSLATED_LANGS as Record<string, string>;
const getLangOptions = (langs: Record<string, string>) => {
const options = Object.entries(langs).map(([value, label]) => ({ value, label }));
options.sort((a, b) => a.label.localeCompare(b.label));
options.unshift({ value: '', label: _('System Language') });
@@ -103,7 +102,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const getCurrentTargetLangOption = () => {
const value = translateTargetLang;
const availableOptions = getLangOptions();
const availableOptions = getLangOptions(TRANSLATOR_LANGS);
return availableOptions.find((o) => o.value === value) || availableOptions[0]!;
};
@@ -153,7 +152,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
<Select
value={getCurrentUILangOption().value}
onChange={handleSelectUILang}
options={getLangOptions()}
options={getLangOptions(TRANSLATED_LANGS)}
/>
</div>
</div>
@@ -200,7 +199,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
<Select
value={getCurrentTargetLangOption().value}
onChange={handleSelectTargetLang}
options={getLangOptions()}
options={getLangOptions(TRANSLATOR_LANGS)}
disabled={!translationEnabled}
/>
</div>
@@ -18,6 +18,7 @@ import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
import { MIGHT_BE_RTL_LANGS } from '@/services/constants';
import { SettingsPanelPanelProp } from './SettingsDialog';
import Select from '@/components/Select';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
@@ -66,9 +67,9 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
const [showBarsOnScroll, setShowBarsOnScroll] = useState(viewSettings.showBarsOnScroll!);
const [showRemainingTime, setShowRemainingTime] = useState(viewSettings.showRemainingTime!);
const [showRemainingPages, setShowRemainingPages] = useState(viewSettings.showRemainingPages!);
const [showPageNumber, setShowPageNumber] = useState(viewSettings.showPageNumber!);
const [showProgressInfo, setShowProgressInfo] = useState(viewSettings.showProgressInfo!);
const [progressStyle, setProgressStyle] = useState(viewSettings.progressStyle);
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation!);
const resetToDefaults = useResetViewSettings();
const handleReset = () => {
@@ -100,7 +101,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
showBarsOnScroll: setShowBarsOnScroll,
showRemainingTime: setShowRemainingTime,
showRemainingPages: setShowRemainingPages,
showPageNumber: setShowPageNumber,
showProgressInfo: setShowProgressInfo,
});
};
@@ -319,9 +320,14 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
}, [showRemainingPages]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'showPageNumber', showPageNumber, false, false);
saveViewSettings(envConfig, bookKey, 'showProgressInfo', showProgressInfo, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPageNumber]);
}, [showProgressInfo]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'progressStyle', progressStyle, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progressStyle]);
useEffect(() => {
if (showHeader === viewSettings.showHeader) return;
@@ -654,13 +660,25 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Page Number')}</span>
<span className=''>{_('Show Reading Progress')}</span>
<input
type='checkbox'
className='toggle'
checked={showPageNumber}
checked={showProgressInfo}
disabled={!showFooter}
onChange={() => setShowPageNumber(!showPageNumber)}
onChange={() => setShowProgressInfo(!showProgressInfo)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Reading Progress Style')}</span>
<Select
value={progressStyle}
onChange={(e) => setProgressStyle(e.target.value as 'percentage' | 'fraction')}
options={[
{ value: 'fraction', label: _('Page Number') },
{ value: 'percentage', label: _('Percentage') },
]}
disabled={!showProgressInfo}
/>
</div>
<div className='config-item'>
@@ -196,9 +196,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
ttsControllerRef.current = null;
}
setTTSEnabled(bookKey, true);
setShowIndicator(true);
try {
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: true });
@@ -223,9 +220,11 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
setTtsController(ttsController);
}
setTtsClientsInitialized(true);
setTTSEnabled(bookKey, true);
setShowIndicator(true);
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported in this device'),
message: _('TTS not supported for this document'),
type: 'error',
});
console.error(error);
@@ -0,0 +1,480 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { md5 } from 'js-md5';
import { type as osType } from '@tauri-apps/plugin-os';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { KOSyncClient, KoSyncProgress } from '@/services/sync/KOSyncClient';
import { Book, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { getCFIFromXPointer, XCFI } from '@/utils/xcfi';
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
export interface SyncDetails {
book: Book;
bookDoc: BookDoc;
local: {
cfi?: string;
preview: string;
};
remote: KoSyncProgress & {
preview: string;
percentage?: number;
};
}
export const useKOSync = (bookKey: string) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { getProgress, getView } = useReaderStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const progress = getProgress(bookKey);
const [syncState, setSyncState] = useState<SyncState>('idle');
const [conflictDetails, setConflictDetails] = useState<SyncDetails | null>(null);
const [errorMessage] = useState<string | null>(null);
const syncCompletedForKey = useRef<string | null>(null);
const lastPushedCfiRef = useRef<string | null>(null);
const bookData = getBookData(bookKey);
const book = bookData?.book;
const bookDoc = bookData?.bookDoc;
useEffect(() => {
lastPushedCfiRef.current = null;
syncCompletedForKey.current = null;
setSyncState('idle');
}, [bookKey]);
const mapProgressToServerFormat = useCallback(() => {
const currentProgress = getProgress(bookKey);
const currentBook = getBookData(bookKey)?.book;
if (!currentProgress || !currentBook) return null;
let progressStr: string;
let percentage: number;
if (FIXED_LAYOUT_FORMATS.has(currentBook.format)) {
const page = (currentProgress.section?.current ?? 0) + 1;
const totalPages = currentProgress.section?.total ?? 0;
progressStr = page.toString();
percentage = totalPages > 0 ? page / totalPages : 0;
} else {
progressStr = currentProgress.location;
const view = getView(bookKey);
if (view && progressStr) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index: spineIndex } = content;
const converter = new XCFI(doc, spineIndex || 0);
const xpointerResult = converter.cfiToXPointer(progressStr);
progressStr = xpointerResult.xpointer;
}
} catch (error) {
console.error(
'Failed to convert CFI to XPointer. Progress will be sent as percentage only.',
error,
);
}
}
const page = currentProgress.pageinfo?.current ?? 0;
const totalPages = currentProgress.pageinfo?.total ?? 0;
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
}
return { progressStr, percentage };
}, [bookKey, getProgress, getBookData, getView]);
const pushProgress = useMemo(
() =>
debounce(async () => {
const { settings: currentSettings } = useSettingsStore.getState();
const currentBook = getBookData(bookKey)?.book;
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = currentSettings;
if (
!koreaderSyncUsername ||
!koreaderSyncUserkey ||
['receive', 'disable'].includes(koreaderSyncStrategy) ||
!currentBook
)
return;
const getDocumentDigest = (bookToDigest: Book): string => {
if (currentSettings.koreaderSyncChecksumMethod === 'filename') {
const filename = bookToDigest.sourceTitle || bookToDigest.title;
const normalizedPath = filename.replace(/\\/g, '/');
return md5(
normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath,
);
}
return bookToDigest.hash;
};
const getDeviceName = async () => {
if (currentSettings.koreaderSyncDeviceName) return currentSettings.koreaderSyncDeviceName;
if (appService?.appPlatform === 'tauri') {
const name = await osType();
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
}
return 'Readest';
};
const digest = getDocumentDigest(currentBook);
const progressData = mapProgressToServerFormat();
if (!digest || !progressData) return;
if (progressData.progressStr === lastPushedCfiRef.current) return;
const deviceName = await getDeviceName();
const client = new KOSyncClient(
currentSettings.koreaderSyncServerUrl,
currentSettings.koreaderSyncUsername,
currentSettings.koreaderSyncUserkey,
currentSettings.koreaderSyncChecksumMethod,
currentSettings.koreaderSyncDeviceId,
deviceName,
);
await client.updateProgress(currentBook, progressData.progressStr, progressData.percentage);
lastPushedCfiRef.current = progressData.progressStr;
}, 5000),
[bookKey, appService, getBookData, mapProgressToServerFormat],
);
useEffect(() => {
const handleFlush = (event: CustomEvent) => {
const { bookKey: syncBookKey } = event.detail;
if (syncBookKey === bookKey) {
pushProgress.flush();
}
};
eventDispatcher.on('flush-koreader-sync', handleFlush);
return () => {
eventDispatcher.off('flush-koreader-sync', handleFlush);
pushProgress.flush();
};
}, [bookKey, pushProgress]);
useEffect(() => {
const performInitialSync = async () => {
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = settings;
if (
!book ||
!bookDoc ||
!progress ||
!koreaderSyncUsername ||
!koreaderSyncUserkey ||
koreaderSyncStrategy === 'disabled'
)
return;
if (koreaderSyncStrategy === 'send') {
syncCompletedForKey.current = bookKey;
setSyncState('synced');
return;
}
setSyncState('checking');
const getDeviceName = async () => {
if (settings.koreaderSyncDeviceName) return settings.koreaderSyncDeviceName;
if (appService?.appPlatform === 'tauri') {
const name = await osType();
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
}
return 'Readest';
};
const deviceName = await getDeviceName();
const client = new KOSyncClient(
settings.koreaderSyncServerUrl,
settings.koreaderSyncUsername,
settings.koreaderSyncUserkey,
settings.koreaderSyncChecksumMethod,
settings.koreaderSyncDeviceId,
deviceName,
);
const remote = await client.getProgress(book);
lastPushedCfiRef.current = progress.location;
if (!remote?.progress || !remote?.timestamp) {
syncCompletedForKey.current = bookKey;
setSyncState('synced');
if (settings.koreaderSyncStrategy !== 'receive') {
pushProgress();
pushProgress.flush();
}
return;
}
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
const remoteIsNewer = remote.timestamp * 1000 > localTimestamp;
const localIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
? progress.section?.current.toString()
: progress.location;
const isLocalCFI = localIdentifier?.startsWith('epubcfi');
const remoteIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
? (parseInt(remote.progress, 10) - 1).toString()
: remote.progress.startsWith('epubcfi')
? remote.progress
: null;
const isRemoteCFI = remoteIdentifier?.startsWith('epubcfi');
let isProgressIdentical = false;
if (isLocalCFI && isRemoteCFI) {
isProgressIdentical = localIdentifier === remoteIdentifier;
}
if (!isProgressIdentical) {
const localPercentage = mapProgressToServerFormat()?.percentage ?? 0;
const remotePercentage = remote.percentage;
if (remotePercentage !== undefined && remotePercentage !== null) {
const tolerance = settings.koreaderSyncPercentageTolerance;
const percentageDifference = Math.abs(localPercentage - remotePercentage);
isProgressIdentical = percentageDifference < tolerance;
}
}
if (isProgressIdentical) {
lastPushedCfiRef.current = localIdentifier;
syncCompletedForKey.current = bookKey;
setSyncState('synced');
return;
}
if (
settings.koreaderSyncStrategy === 'receive' ||
(settings.koreaderSyncStrategy === 'silent' && remoteIsNewer)
) {
const applyRemoteProgress = async () => {
const view = getView(bookKey);
if (view && remote.progress) {
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const pageToGo = parseInt(remote.progress, 10);
if (!isNaN(pageToGo)) view.select(pageToGo - 1);
} else {
const isXPointer = remote.progress.startsWith('/body');
if (isXPointer) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index } = content;
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
view.goTo(cfi);
eventDispatcher.dispatch('toast', {
message: _('Reading Progress Synced'),
type: 'info',
});
}
} catch (error) {
console.error(
'Failed to convert XPointer to CFI, falling back to percentage.',
error,
);
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
} else {
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
}
eventDispatcher.dispatch('toast', {
message: _('Reading Progress Synced'),
type: 'info',
});
}
};
applyRemoteProgress();
syncCompletedForKey.current = bookKey;
setSyncState('synced');
} else if (settings.koreaderSyncStrategy === 'prompt') {
let localPreview = '';
let remotePreview = '';
const remotePercentage = remote.percentage || 0;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const localPageInfo = progress.section;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = localPageInfo
? _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: localPageInfo.current + 1,
total: localPageInfo.total,
percentage: localPercentage,
})
: _('Current position');
const remotePage = parseInt(remote.progress, 10);
if (!isNaN(remotePage) && remotePercentage > 0) {
const localTotalPages = localPageInfo?.total ?? 0;
const remoteTotalPages = Math.round(remotePage / remotePercentage);
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
if (pagesMatch) {
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
} else {
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
}
} else {
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
} else {
const localPageInfo = progress.pageinfo;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = `${progress.sectionLabel} (${localPercentage}%)`;
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
setConflictDetails({
book,
bookDoc,
local: { cfi: progress.location, preview: localPreview },
remote: { ...remote, preview: remotePreview, percentage: remote.percentage },
});
setSyncState('conflict');
} else {
syncCompletedForKey.current = bookKey;
setSyncState('synced');
}
};
if (bookKey && book && progress && syncCompletedForKey.current !== bookKey) {
syncCompletedForKey.current = bookKey;
performInitialSync();
}
}, [
bookKey,
book,
bookDoc,
progress,
settings,
appService,
getBookData,
getProgress,
getView,
mapProgressToServerFormat,
pushProgress,
_,
bookData?.config?.updatedAt,
]);
useEffect(() => {
if (syncState === 'synced' && progress) {
if (
settings.koreaderSyncStrategy !== 'receive' &&
settings.koreaderSyncStrategy !== 'disabled'
) {
pushProgress();
}
}
}, [progress, syncState, settings.koreaderSyncStrategy, pushProgress]);
useEffect(() => {
return () => {
pushProgress.flush();
};
}, [pushProgress]);
const resolveConflictWithLocal = () => {
pushProgress();
pushProgress.flush();
setSyncState('synced');
setConflictDetails(null);
};
const resolveConflictWithRemote = async () => {
const view = getView(bookKey);
const remote = conflictDetails?.remote;
const currentBook = conflictDetails?.book;
const bookDoc = conflictDetails?.bookDoc;
if (view && remote?.progress && currentBook) {
if (FIXED_LAYOUT_FORMATS.has(currentBook.format)) {
const localTotalPages = getProgress(bookKey)?.section?.total ?? 0;
const remotePage = parseInt(remote.progress, 10);
const remotePercentage = remote.percentage || 0;
const remoteTotalPages =
remotePercentage > 0 ? Math.round(remotePage / remotePercentage) : 0;
if (!isNaN(remotePage) && Math.abs(localTotalPages - remoteTotalPages) <= 1) {
console.log('Going to remote page:', remotePage);
view.select(remotePage - 1);
} else if (remote.percentage !== undefined && remote.percentage !== null) {
console.log('Going to remote percentage:', remote.percentage);
view.goToFraction(remote.percentage);
}
} else {
const isXPointer = remote.progress.startsWith('/body');
const isCFI = remote.progress.startsWith('epubcfi');
if (isXPointer) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index } = content;
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
view.goTo(cfi);
}
} catch (error) {
console.error('Failed to convert XPointer to CFI, falling back to percentage.', error);
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
} else if (isCFI) {
view.goTo(remote.progress);
} else if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
eventDispatcher.dispatch('toast', { message: _('Reading Progress Synced'), type: 'info' });
}
setSyncState('synced');
setConflictDetails(null);
};
return {
syncState,
conflictDetails,
errorMessage,
resolveConflictWithLocal,
resolveConflictWithRemote,
pushProgress,
};
};
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useSync } from '@/hooks/useSync';
import { BookConfig } from '@/types/book';
import { BookConfig, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -11,15 +11,15 @@ import { CFI } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
export const useProgressSync = (bookKey: string) => {
const _ = useTranslation();
const { getConfig, setConfig } = useBookDataStore();
const { getConfig, setConfig, getBookData } = useBookDataStore();
const { getView, getProgress } = useReaderStore();
const { settings } = useSettingsStore();
const { syncedConfigs, syncConfigs } = useSync(bookKey);
const { user } = useAuth();
const view = getView(bookKey);
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
@@ -29,24 +29,38 @@ export const useProgressSync = (bookKey: string) => {
const pushConfig = (bookKey: string, config: BookConfig | null) => {
if (!config || !user) return;
const bookHash = bookKey.split('-')[0]!;
const newConfig = { bookHash, ...config };
const newConfig = { ...config, bookHash };
const compressedConfig = JSON.parse(
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
);
delete compressedConfig.booknotes;
syncConfigs([compressedConfig], bookHash, 'push');
};
const pullConfig = (bookKey: string) => {
if (!user) return;
const bookHash = bookKey.split('-')[0]!;
syncConfigs([], bookHash, 'pull');
};
const syncConfig = () => {
const syncConfig = async () => {
if (!configPulled.current) {
pullConfig(bookKey);
} else {
const config = getConfig(bookKey);
if (config && config.progress && config.progress[0] > 0) {
const view = getView(bookKey);
const book = getBookData(bookKey)?.book;
if (config && view && book && config.progress && config.progress[0] > 0) {
try {
const content = view.renderer.getContents()[0];
if (content && !FIXED_LAYOUT_FORMATS.has(book.format)) {
const { doc, index } = content;
const xpointerResult = await getXPointerFromCFI(config.location!, doc, index || 0);
config.xpointer = xpointerResult.xpointer;
}
} catch (error) {
console.error('Failed to convert CFI to XPointer', error);
}
pushConfig(bookKey, config);
}
}
@@ -91,28 +105,51 @@ export const useProgressSync = (bookKey: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
// Pull: proccess the pulled progress
useEffect(() => {
if (!configPulled.current && syncedConfigs) {
configPulled.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const configCFI = config?.location;
const syncedCFI = syncedConfig.location;
setConfig(bookKey, syncedConfig);
if (syncedCFI && configCFI) {
if (CFI.compare(configCFI, syncedCFI) < 0) {
if (view) {
view.goTo(syncedCFI);
eventDispatcher.dispatch('hint', {
bookKey,
message: _('Reading Progress Synced'),
});
}
const applyRemoteProgress = useCallback(async () => {
if (!syncedConfigs || syncedConfigs.length === 0) return;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const configCFI = config?.location;
let remoteCFILocation = syncedConfig.location;
const xPointer = syncedConfig.xpointer;
const bookData = getBookData(bookKey);
const view = getView(bookKey);
if (xPointer && view && bookData && bookData.bookDoc) {
const content = view.renderer.getContents()[0];
const candidateCFI = await getCFIFromXPointer(
xPointer,
content?.doc,
content?.index,
bookData.bookDoc,
);
if (CFI.compare(remoteCFILocation, candidateCFI) < 0) {
remoteCFILocation = candidateCFI;
}
}
setConfig(bookKey, syncedConfig);
if (remoteCFILocation && configCFI) {
if (CFI.compare(configCFI, remoteCFILocation) < 0) {
if (view) {
view.goTo(remoteCFILocation);
eventDispatcher.dispatch('hint', {
bookKey,
message: _('Reading Progress Synced'),
});
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [syncedConfigs]);
}, [syncedConfigs, config?.location]);
// Pull: proccess the pulled progress
useEffect(() => {
if (!configPulled.current && syncedConfigs) {
configPulled.current = true;
applyRemoteProgress().catch((error) => {
console.error('Failed to apply remote progress', error);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [applyRemoteProgress]);
};
@@ -48,6 +48,7 @@ const DeleteConfirmationModal: React.FC<DeleteConfirmationModalProps> = ({
interface AccountActionsProps {
userPlan: UserPlan;
onLogout: () => void;
onResetPassword: () => void;
onConfirmDelete: () => void;
onRestorePurchase?: () => void;
onManageSubscription?: () => void;
@@ -56,6 +57,7 @@ interface AccountActionsProps {
const AccountActions: React.FC<AccountActionsProps> = ({
userPlan,
onLogout,
onResetPassword,
onConfirmDelete,
onRestorePurchase,
onManageSubscription,
@@ -100,6 +102,12 @@ const AccountActions: React.FC<AccountActionsProps> = ({
</button>
)
)}
<button
onClick={onResetPassword}
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
>
{_('Reset Password')}
</button>
<button
onClick={onLogout}
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
+6 -1
View File
@@ -12,7 +12,7 @@ import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { UserPlan } from '@/types/user';
import { navigateToLibrary } from '@/utils/nav';
import { navigateToLibrary, navigateToResetPassword } from '@/utils/nav';
import { deleteUser } from '@/libs/user';
import { eventDispatcher } from '@/utils/event';
import { getStripe } from '@/libs/stripe/client';
@@ -91,6 +91,10 @@ const ProfilePage = () => {
navigateToLibrary(router);
};
const handleResetPassword = () => {
navigateToResetPassword(router);
};
const handleConfirmDelete = async () => {
try {
await deleteUser();
@@ -388,6 +392,7 @@ const ProfilePage = () => {
<AccountActions
userPlan={userPlan}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onConfirmDelete={handleConfirmDelete}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
+48 -3
View File
@@ -1,15 +1,54 @@
import clsx from 'clsx';
import React, { useState, isValidElement, ReactElement } from 'react';
import React, { useState, isValidElement, ReactElement, ReactNode } from 'react';
import MenuItem from './MenuItem';
interface DropdownProps {
className?: string;
menuClassName?: string;
buttonClassName?: string;
toggleButton: React.ReactNode;
children: ReactElement<{ setIsDropdownOpen: (isOpen: boolean) => void; menuClassName?: string }>;
children: ReactElement<{
setIsDropdownOpen: (isOpen: boolean) => void;
menuClassName?: string;
children: ReactNode;
}>;
onToggle?: (isOpen: boolean) => void;
}
const enhanceMenuItems = (
children: ReactNode,
setIsDropdownOpen: (isOpen: boolean) => void,
): ReactNode => {
const processNode = (node: ReactNode): ReactNode => {
if (!isValidElement(node)) {
return node;
}
const element = node as ReactElement;
const isMenuItem =
element.type === MenuItem ||
(typeof element.type === 'function' && element.type.name === 'MenuItem');
const clonedElement = isMenuItem
? React.cloneElement(element, {
setIsDropdownOpen,
...element.props,
})
: element;
if (clonedElement.props?.children) {
return React.cloneElement(clonedElement, {
...clonedElement.props,
children: React.Children.map(clonedElement.props.children, processNode),
});
}
return clonedElement;
};
return React.Children.map(children, processNode);
};
const Dropdown: React.FC<DropdownProps> = ({
className,
menuClassName,
@@ -32,7 +71,13 @@ const Dropdown: React.FC<DropdownProps> = ({
};
const childrenWithToggle = isValidElement(children)
? React.cloneElement(children, { setIsDropdownOpen, menuClassName })
? React.cloneElement(children, {
...(typeof children.type !== 'string' && {
setIsDropdownOpen,
menuClassName,
}),
children: enhanceMenuItems(children.props?.children, setIsDropdownOpen),
})
: children;
return (
+11 -1
View File
@@ -12,9 +12,11 @@ interface MenuItemProps {
shortcut?: string;
disabled?: boolean;
noIcon?: boolean;
transient?: boolean; // For transient items the dropdown will close on click
Icon?: React.ReactNode | IconType;
children?: React.ReactNode;
onClick?: () => void;
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const MenuItem: React.FC<MenuItemProps> = ({
@@ -26,9 +28,11 @@ const MenuItem: React.FC<MenuItemProps> = ({
shortcut,
disabled,
noIcon = false,
transient = false,
Icon,
children,
onClick,
setIsDropdownOpen,
}) => {
const iconSize = useResponsiveSize(16);
const menuButton = (
@@ -38,8 +42,14 @@ const MenuItem: React.FC<MenuItemProps> = ({
disabled && 'btn-disabled text-gray-400',
buttonClass,
)}
aria-label={label}
data-tip={tooltip ? tooltip : ''}
onClick={onClick}
onClick={() => {
onClick?.();
if (transient) {
setIsDropdownOpen?.(false);
}
}}
disabled={disabled}
>
<div className='flex w-full items-center justify-between'>
+16 -8
View File
@@ -1,23 +1,31 @@
'use client';
import { useEffect } from 'react';
import { IconContext } from 'react-icons';
import { AuthProvider } from '@/context/AuthContext';
import { EnvProvider } from '@/context/EnvContext';
import { useEnv } from '@/context/EnvContext';
import { CSPostHogProvider } from '@/context/PHContext';
import { SyncProvider } from '@/context/SyncContext';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { initSystemThemeListener } from '@/store/themeStore';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { appService } = useEnv();
const iconSize = useDefaultIconSize();
useEffect(() => {
if (appService) {
initSystemThemeListener(appService);
}
}, [appService]);
return (
<CSPostHogProvider>
<EnvProvider>
<AuthProvider>
<IconContext.Provider value={{ size: `${iconSize}px` }}>
<SyncProvider>{children}</SyncProvider>
</IconContext.Provider>
</AuthProvider>
</EnvProvider>
<AuthProvider>
<IconContext.Provider value={{ size: `${iconSize}px` }}>
<SyncProvider>{children}</SyncProvider>
</IconContext.Provider>
</AuthProvider>
</CSPostHogProvider>
);
};
+1 -1
View File
@@ -27,7 +27,7 @@ export default function Select({
onChange={onChange}
className={clsx(
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
'focus:outline-none focus:ring-0',
'bg-base-200 focus:outline-none focus:ring-0',
className,
)}
disabled={disabled}
@@ -7,6 +7,7 @@ import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useMetadataEdit } from './useMetadataEdit';
import { DeleteAction } from '@/types/system';
import Alert from '@/components/Alert';
import Dialog from '@/components/Dialog';
import Spinner from '@/components/Spinner';
@@ -22,9 +23,16 @@ interface BookDetailModalProps {
handleBookUpload?: (book: Book) => void;
handleBookDelete?: (book: Book) => void;
handleBookDeleteCloudBackup?: (book: Book) => void;
handleBookDeleteLocalCopy?: (book: Book) => void;
handleBookMetadataUpdate?: (book: Book, updatedMetadata: BookMetadata) => void;
}
interface DeleteConfig {
title: string;
message: string;
handler?: (book: Book) => void;
}
const BookDetailModal: React.FC<BookDetailModalProps> = ({
book,
isOpen,
@@ -33,12 +41,12 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
handleBookUpload,
handleBookDelete,
handleBookDeleteCloudBackup,
handleBookDeleteLocalCopy,
handleBookMetadataUpdate,
}) => {
const _ = useTranslation();
const [loading, setLoading] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showDeleteCloudBackupAlert, setShowDeleteCloudBackupAlert] = useState(false);
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
const [editMode, setEditMode] = useState(false);
const [bookMeta, setBookMeta] = useState<BookMetadata | null>(null);
const [fileSize, setFileSize] = useState<number | null>(null);
@@ -64,6 +72,24 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
resetToOriginal,
} = useMetadataEdit(bookMeta);
const deleteConfigs: Record<DeleteAction, DeleteConfig> = {
both: {
title: _('Confirm Deletion'),
message: _('Are you sure to delete the selected book?'),
handler: handleBookDelete,
},
cloud: {
title: _('Confirm Deletion'),
message: _('Are you sure to delete the cloud backup of the selected book?'),
handler: handleBookDeleteCloudBackup,
},
local: {
title: _('Confirm Deletion'),
message: _('Are you sure to delete the local copy of the selected book?'),
handler: handleBookDeleteLocalCopy,
},
};
useEffect(() => {
const loadingTimeout = setTimeout(() => setLoading(true), 300);
const fetchBookDetails = async () => {
@@ -85,6 +111,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
const handleClose = () => {
setBookMeta(null);
setEditMode(false);
setActiveDeleteAction(null);
onClose();
};
@@ -105,30 +132,29 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
}
};
const handleDelete = () => {
setShowDeleteAlert(true);
const handleDeleteAction = (action: DeleteAction) => {
setActiveDeleteAction(action);
};
const handleDeleteCloudBackup = () => {
setShowDeleteCloudBackupAlert(true);
};
const confirmDeleteAction = async () => {
if (!activeDeleteAction) return;
const confirmDelete = async () => {
const config = deleteConfigs[activeDeleteAction];
handleClose();
setShowDeleteAlert(false);
if (handleBookDelete) {
handleBookDelete(book);
if (config.handler) {
config.handler(book);
}
};
const confirmDeleteCloudBackup = async () => {
handleClose();
setShowDeleteCloudBackupAlert(false);
if (handleBookDeleteCloudBackup) {
handleBookDeleteCloudBackup(book);
}
const cancelDeleteAction = () => {
setActiveDeleteAction(null);
};
const handleDelete = () => handleDeleteAction('both');
const handleDeleteCloudBackup = () => handleDeleteAction('cloud');
const handleDeleteLocalCopy = () => handleDeleteAction('local');
const handleRedownload = async () => {
handleClose();
if (handleBookDownload) {
@@ -143,6 +169,8 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
}
};
const currentDeleteConfig = activeDeleteAction ? deleteConfigs[activeDeleteAction] : null;
if (!bookMeta)
return (
loading && (
@@ -194,6 +222,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
onDeleteCloudBackup={
handleBookDeleteCloudBackup ? handleDeleteCloudBackup : undefined
}
onDeleteLocalCopy={handleBookDeleteLocalCopy ? handleDeleteLocalCopy : undefined}
onDownload={handleBookDownload ? handleRedownload : undefined}
onUpload={handleBookUpload ? handleReupload : undefined}
/>
@@ -209,8 +238,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
onClose={handleCloseSourceSelection}
/>
{/* Delete Alerts */}
{showDeleteAlert && (
{activeDeleteAction && currentDeleteConfig && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
@@ -218,25 +246,10 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
)}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected book?')}
onCancel={() => setShowDeleteAlert(false)}
onConfirm={confirmDelete}
/>
</div>
)}
{showDeleteCloudBackupAlert && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the cloud backup of the selected book?')}
onCancel={() => setShowDeleteCloudBackupAlert(false)}
onConfirm={confirmDeleteCloudBackup}
title={currentDeleteConfig.title}
message={currentDeleteConfig.message}
onCancel={cancelDeleteAction}
onConfirm={confirmDeleteAction}
/>
</div>
)}
@@ -1,9 +1,9 @@
import clsx from 'clsx';
import React from 'react';
import {
MdOutlineDelete,
MdOutlineCloudDownload,
MdOutlineCloudUpload,
MdOutlineCloudOff,
MdOutlineDelete,
MdOutlineEdit,
} from 'react-icons/md';
@@ -19,6 +19,8 @@ import {
formatTitle,
} from '@/utils/book';
import BookCover from '@/components/BookCover';
import Dropdown from '../Dropdown';
import MenuItem from '../MenuItem';
interface BookDetailViewProps {
book: Book;
@@ -27,6 +29,7 @@ interface BookDetailViewProps {
onEdit?: () => void;
onDelete?: () => void;
onDeleteCloudBackup?: () => void;
onDeleteLocalCopy?: () => void;
onDownload?: () => void;
onUpload?: () => void;
}
@@ -38,6 +41,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
onEdit,
onDelete,
onDeleteCloudBackup,
onDeleteLocalCopy,
onDownload,
onUpload,
}) => {
@@ -65,14 +69,40 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
</button>
)}
{onDelete && (
<button onClick={onDelete}>
<MdOutlineDelete className='fill-red-500' />
</button>
)}
{book.uploadedAt && onDeleteCloudBackup && (
<button onClick={onDeleteCloudBackup}>
<MdOutlineCloudOff className='fill-red-500' />
</button>
<Dropdown
className='dropdown-bottom flex justify-center'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineDelete className='fill-red-500' />}
>
<div
tabIndex={0}
className={clsx(
'delete-menu dropdown-content dropdown-center no-triangle',
'border-base-200 z-20 mt-1 max-w-[90vw] shadow-2xl',
)}
>
<MenuItem
noIcon
transient
label={_('Remove from Cloud & Device')}
onClick={onDelete}
/>
<MenuItem
noIcon
transient
label={_('Remove from Cloud Only')}
onClick={onDeleteCloudBackup}
disabled={!book.uploadedAt}
/>
<MenuItem
noIcon
transient
label={_('Remove from Device Only')}
onClick={onDeleteLocalCopy}
disabled={!book.downloadedAt}
/>
</div>
</Dropdown>
)}
{book.uploadedAt && onDownload && (
<button onClick={onDownload}>
+2
View File
@@ -57,6 +57,8 @@ export interface SectionItem {
size: number;
linear: string;
location?: Location;
createDocument: () => Promise<Document>;
}
export type BookMetadata = {
+6 -3
View File
@@ -1,5 +1,6 @@
import { AppProps } from 'next/app';
import Head from 'next/head';
import { EnvProvider } from '@/context/EnvContext';
import Providers from '@/components/Providers';
import '../styles/globals.css';
@@ -25,9 +26,11 @@ function MyApp({ Component, pageProps }: AppProps) {
<meta name='theme-color' content='white' />
<link rel='manifest' href='/manifest.json' />
</Head>
<Providers>
<Component {...pageProps} />
</Providers>
<EnvProvider>
<Providers>
<Component {...pageProps} />
</Providers>
</EnvProvider>
</>
);
}
@@ -4,6 +4,7 @@ import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { getDailyTranslationPlanData, getUserPlan, validateUserAndToken } from '@/utils/access';
import { ErrorCodes } from '@/services/translators';
import { UsageStatsManager } from '@/utils/usage';
const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
@@ -34,47 +35,36 @@ const generateCacheKey = (text: string, sourceLang: string, targetLang: string):
return `tr:${hash}`;
};
const getDailyUsageKey = (userId?: string, token?: string) => {
let dailyUsageKey = '';
if (userId && token) {
const currentDate = new Date().toISOString().split('T')[0]!;
dailyUsageKey = `daily_usage:${currentDate}:${userId}`;
}
return dailyUsageKey;
};
const checkDailyUsage = async (
userId: string,
token: string,
chars: number,
translationsKV?: KVNamespace,
) => {
const usageKey = getDailyUsageKey(userId, token);
const dailyUsage = (await translationsKV?.get(usageKey)) || '0';
const checkDailyUsage = async (userId: string, token: string, chars: number) => {
const { quota: dailyQuota } = getDailyTranslationPlanData(token);
const dailyUsageChars = parseInt(dailyUsage);
if (dailyQuota <= dailyUsageChars + chars) {
const dailyUsage = await UsageStatsManager.getCurrentUsage(userId, 'translation_chars', 'daily');
if (dailyQuota <= dailyUsage + chars) {
throw new Error(ErrorCodes.DAILY_QUOTA_EXCEEDED);
}
return dailyUsageChars;
return dailyUsage;
};
const updateDailyUsage = async (
userId: string | undefined,
token: string | undefined,
newUsage: number,
translationsKV?: KVNamespace,
incrementUsage: number,
) => {
const usageKey = getDailyUsageKey(userId, token);
if (!usageKey) return 0;
if (!userId || !token) return 0;
try {
const dailyUsage = (await translationsKV?.get(usageKey)) || '0';
const newDailyUsage = parseInt(dailyUsage) + newUsage;
await translationsKV?.put(usageKey, newDailyUsage.toString(), {
expirationTtl: 86400 * 30,
});
return newDailyUsage;
const userPlan = getUserPlan(token);
const newUsage = await UsageStatsManager.trackUsage(
userId,
'translation_chars',
incrementUsage,
{
plan_type: userPlan,
source: 'deepl_api',
},
);
return newUsage;
} catch (cacheError) {
console.error('Update daily usage error:', cacheError);
}
@@ -139,7 +129,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
await checkDailyUsage(user?.id, token, singleText.length, env['TRANSLATIONS_KV']);
await checkDailyUsage(user?.id, token, singleText.length);
return await callDeepLAPI(
singleText,
@@ -158,7 +148,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
user?.id,
token,
originalCharsCount + translatedCharsCount,
env['TRANSLATIONS_KV'],
);
translations.forEach((translation) => {
if (translation && translation.text) {
+54
View File
@@ -0,0 +1,54 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { KoSyncProxyPayload } from '@/types/kosync';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
const {
serverUrl,
endpoint,
method,
headers: clientHeaders,
body: clientBody,
} = req.body as KoSyncProxyPayload;
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
if (!serverUrl || !endpoint) {
return res.status(400).json({ error: 'serverUrl and endpoint are required' });
}
const targetUrl = `${serverUrl.replace(/\/$/, '')}${endpoint}`;
try {
const response = await fetch(targetUrl, {
method: method,
headers: {
...clientHeaders,
Accept: 'application/vnd.koreader.v1+json',
'Content-Type': 'application/json',
},
body: clientBody ? JSON.stringify(clientBody) : null,
});
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Invalid sync server response: Unexpected Content-Type.');
}
const data = await response.text();
res.status(response.status);
try {
res.json(JSON.parse(data));
} catch {
res.send(data);
}
} catch (error) {
console.error('[KOSYNC PROXY] Error:', error);
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
res.status(500).json({ error: 'Proxy request failed', details: errorMessage });
}
}
+24 -15
View File
@@ -1,7 +1,7 @@
import { AppPlatform, AppService, OsPlatform } from '@/types/system';
import { v4 as uuidv4 } from 'uuid';
import { SystemSettings } from '@/types/settings';
import { FileSystem, BaseDir } from '@/types/system';
import { AppPlatform, AppService, OsPlatform } from '@/types/system';
import { FileSystem, BaseDir, DeleteAction } from '@/types/system';
import { Book, BookConfig, BookContent, BookFormat, ViewSettings } from '@/types/book';
import {
getDir,
@@ -116,11 +116,17 @@ export abstract class BaseAppService implements AppService {
...this.getDefaultViewSettings(),
...settings.globalViewSettings,
};
if (!settings.koreaderSyncDeviceId) {
settings.koreaderSyncDeviceId = uuidv4();
await this.fs.writeFile(fp, base, JSON.stringify(settings));
}
} catch {
settings = {
...DEFAULT_SYSTEM_SETTINGS,
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await this.fs.getPrefix('Books'),
koreaderSyncDeviceId: uuidv4(),
globalReadSettings: {
...DEFAULT_READSETTINGS,
...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
@@ -273,16 +279,27 @@ export abstract class BaseAppService implements AppService {
}
}
async deleteBook(book: Book, includingUploaded = false, includingLocal = true): Promise<void> {
if (includingLocal) {
const localDeleteFps = [getLocalBookFilename(book), getCoverFilename(book)];
async deleteBook(book: Book, deleteAction: DeleteAction): Promise<void> {
console.log('Deleting book with action:', deleteAction, book.title);
if (deleteAction === 'local' || deleteAction === 'both') {
const localDeleteFps =
deleteAction === 'local'
? [getLocalBookFilename(book)]
: [getLocalBookFilename(book), getCoverFilename(book)];
for (const fp of localDeleteFps) {
if (await this.fs.exists(fp, 'Books')) {
await this.fs.removeFile(fp, 'Books');
}
}
if (deleteAction === 'local') {
book.downloadedAt = null;
} else {
book.deletedAt = Date.now();
book.downloadedAt = null;
book.coverDownloadedAt = null;
}
}
if (includingUploaded) {
if (deleteAction === 'cloud' || deleteAction === 'both') {
const fps = [getRemoteBookFilename(book), getCoverFilename(book)];
for (const fp of fps) {
console.log('Deleting uploaded file:', fp);
@@ -293,14 +310,6 @@ export abstract class BaseAppService implements AppService {
console.log('Failed to delete uploaded file:', error);
}
}
}
if (includingLocal) {
book.deletedAt = Date.now();
book.downloadedAt = null;
book.coverDownloadedAt = null;
}
if (includingUploaded) {
book.uploadedAt = null;
}
}
+32 -1
View File
@@ -53,6 +53,15 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
librarySortAscending: false,
libraryCoverFit: 'crop',
koreaderSyncServerUrl: 'https://sync.koreader.rocks/', // https://kosync.ak-team.com:3042/
koreaderSyncUsername: '',
koreaderSyncUserkey: '',
koreaderSyncDeviceId: '',
koreaderSyncDeviceName: '',
koreaderSyncChecksumMethod: 'binary',
koreaderSyncStrategy: 'prompt',
koreaderSyncPercentageTolerance: 0.00001,
lastSyncedAtBooks: 0,
lastSyncedAtConfigs: 0,
lastSyncedAtNotes: 0,
@@ -165,7 +174,8 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
showBarsOnScroll: false,
showRemainingTime: false,
showRemainingPages: false,
showPageNumber: true,
showProgressInfo: true,
progressStyle: 'fraction',
};
export const DEFAULT_TTS_CONFIG: TTSConfig = {
@@ -654,8 +664,29 @@ export const TRANSLATED_LANGS = {
id: 'Indonesia',
vi: 'Tiếng Việt',
th: 'ภาษาไทย',
bo: 'བོད་སྐད་',
'zh-CN': '简体中文',
'zh-TW': '正體中文',
};
export const TRANSLATOR_LANGS: Record<string, string> = {
...TRANSLATED_LANGS,
no: 'Norsk',
sv: 'Svenska',
fi: 'Suomi',
da: 'Dansk',
cs: 'Čeština',
hu: 'Magyar',
ro: 'Română',
bg: 'Български',
hr: 'Hrvatski',
lt: 'Lietuvių',
sl: 'Slovenščina',
sk: 'Slovenčina',
};
export const SUPPORTED_LANGS: Record<string, string> = { ...TRANSLATED_LANGS, zh: '中文' };
export const SUPPORTED_LANGNAMES: Record<string, string> = Object.fromEntries(
Object.entries(SUPPORTED_LANGS).map(([code, name]) => [name, code]),
);
@@ -27,7 +27,7 @@ import { type as osType } from '@tauri-apps/plugin-os';
import { Book } from '@/types/book';
import { FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getOSPlatform, isContentURI, isFileURI, isValidURL } from '@/utils/misc';
import { getOSPlatform, isContentURI, isValidURL } from '@/utils/misc';
import { getCoverFilename, getFilename } from '@/utils/book';
import { copyURIToPath } from '@/utils/bridge';
import { NativeFile, RemoteFile } from '@/utils/file';
@@ -119,10 +119,7 @@ export const nativeFileSystem: FileSystem = {
}
} else {
const prefix = await this.getPrefix(base);
if (isFileURI(path)) {
path = path.replace(/^file:\/\//, '');
}
const absolutePath = path.startsWith('/') ? path : await join(prefix, path);
const absolutePath = path.startsWith('/') ? path : prefix ? await join(prefix, path) : null;
if (absolutePath && OS_TYPE !== 'android') {
// NOTE: RemoteFile currently performs about 2× faster than NativeFile
// due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190).
@@ -0,0 +1,218 @@
import { md5 } from 'js-md5';
import { Book } from '@/types/book';
import { KoreaderSyncChecksumMethod } from '@/types/settings';
import { getAPIBaseUrl } from '../environment';
import { KoSyncProxyPayload } from '@/types/kosync';
/**
* Interface for KOSync progress response from the server
*/
export interface KoSyncProgress {
document?: string;
progress?: string;
percentage?: number;
timestamp?: number;
device?: string;
device_id?: string;
}
export class KOSyncClient {
private serverUrl: string;
private username: string;
private userkey: string;
private checksumMethod: KoreaderSyncChecksumMethod;
private deviceId: string;
private deviceName: string;
constructor(
serverUrl: string,
username: string,
userkey: string,
checksumMethod: KoreaderSyncChecksumMethod,
deviceId: string,
deviceName: string,
) {
this.serverUrl = serverUrl.replace(/\/$/, '');
this.username = username;
this.userkey = userkey;
this.checksumMethod = checksumMethod;
this.deviceId = deviceId;
this.deviceName = deviceName;
}
private async request(
endpoint: string,
options: {
method?: 'GET' | 'POST' | 'PUT';
body?: BodyInit | null;
headers?: HeadersInit;
useAuth?: boolean;
} = {},
): Promise<Response> {
const { method = 'GET', body, headers: additionalHeaders, useAuth = true } = options;
const headers = new Headers(additionalHeaders || {});
if (useAuth) {
headers.set('X-Auth-User', this.username);
headers.set('X-Auth-Key', this.userkey);
}
const proxyUrl = `${getAPIBaseUrl()}/kosync`;
const proxyBody: KoSyncProxyPayload = {
serverUrl: this.serverUrl,
endpoint,
method,
headers: Object.fromEntries(headers.entries()),
body: body ? JSON.parse(body as string) : undefined,
};
return fetch(proxyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(proxyBody),
});
}
/**
* Connects to the KOSync server with authentication
* @param username - The username for authentication
* @param password - The password for authentication
* @returns Promise with success status and optional message
*/
async connect(
username: string,
password: string,
): Promise<{ success: boolean; message?: string }> {
const userkey = md5(password);
try {
const authResponse = await this.request('/users/auth', {
method: 'GET',
headers: {
'X-Auth-User': username,
'X-Auth-Key': userkey,
},
});
if (authResponse.ok) {
return { success: true, message: 'Login successful.' };
}
if (authResponse.status === 401) {
const registerResponse = await this.request('/users/create', {
method: 'POST',
useAuth: false,
body: JSON.stringify({ username, password: userkey }),
});
if (registerResponse.ok) {
return { success: true, message: 'Registration successful.' };
}
const regError = await registerResponse.json().catch(() => ({}));
if (registerResponse.status === 402) {
return { success: false, message: 'Invalid credentials.' };
}
return { success: false, message: regError.message || 'Registration failed.' };
}
const errorBody = await authResponse.json().catch(() => ({}));
return {
success: false,
message: errorBody.message || `Authorization failed with status: ${authResponse.status}`,
};
} catch (e) {
console.error('KOSync connection failed', e);
return { success: false, message: (e as Error).message || 'Connection error.' };
}
}
/**
* Retrieves the reading progress for a specific book from the server
* @param book - The book to get progress for
* @returns Promise with the progress data or null if not found
*/
async getProgress(book: Book): Promise<KoSyncProgress | null> {
if (!this.userkey) return null;
const documentHash = this.getDocumentDigest(book);
if (!documentHash) return null;
try {
const response = await this.request(`/syncs/progress/${documentHash}`);
if (!response.ok) {
console.error(
`KOSync: Failed to get progress for ${book.title}. Status: ${response.status}`,
);
return null;
}
const data = await response.json();
return data.document ? data : null;
} catch (e) {
console.error('KOSync getProgress failed', e);
return null;
}
}
/**
* Updates the reading progress for a specific book on the server
* @param book - The book to update progress for
* @param progress - The current reading progress position
* @param percentage - The reading completion percentage
* @returns Promise with boolean indicating success
*/
async updateProgress(book: Book, progress: string, percentage: number): Promise<boolean> {
if (!this.userkey) return false;
const documentHash = this.getDocumentDigest(book);
if (!documentHash) return false;
const payload = {
document: documentHash,
progress: progress.toString(),
percentage,
device: this.deviceName,
device_id: this.deviceId,
};
try {
const response = await this.request('/syncs/progress', {
method: 'PUT',
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error(
`KOSync: Failed to update progress for ${book.title}. Status: ${response.status}`,
);
return false;
}
return true;
} catch (e) {
console.error('KOSync updateProgress failed', e);
return false;
}
}
private getDocumentDigest(book: Book): string | undefined {
if (this.checksumMethod === 'filename') {
const filename = this.getBaseFilename(book.sourceTitle || book.title);
return md5(filename);
}
return book.hash;
}
private getBaseFilename(fullPath: string): string {
// Normalize path by replacing backslashes with forward slashes for cross-platform consistency
const normalizedPath = fullPath.replace(/\\/g, '/');
// Get the last part of the path and remove the extension
const baseName =
normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath;
return baseName;
}
}
@@ -1,9 +1,11 @@
import type { Transformer } from './types';
import { footnoteTransformer } from './footnote';
import { languageTransformer } from './language';
import { punctuationTransformer } from './punctuation';
export const availableTransformers: Transformer[] = [
punctuationTransformer,
footnoteTransformer,
languageTransformer,
// Add more transformers here
];
@@ -0,0 +1,28 @@
import { detectLanguage, isValidLang } from '@/utils/lang';
import type { Transformer } from './types';
export const languageTransformer: Transformer = {
name: 'language',
transform: async (ctx) => {
let result = ctx.content;
const attrsMatch = result.match(/<html\b([^>]*)>/i);
if (attrsMatch) {
let attrs = attrsMatch[1] || '';
const langRegex = / lang="([^"]*)"/i;
const xmlLangRegex = / xml:lang="([^"]*)"/i;
const xmlLangMatch = attrs.match(xmlLangRegex);
const langMatch = attrs.match(langRegex);
if (!isValidLang(langMatch?.[1] || xmlLangMatch?.[1])) {
const mainContent = result.replace(/<[^>]+>/g, ' ');
const lang = detectLanguage(mainContent);
const newLangAttr = ` lang="${lang}"`;
const newXmlLangAttr = ` xml:lang="${lang}"`;
attrs = langMatch ? attrs.replace(langRegex, newLangAttr) : attrs + newLangAttr;
attrs = xmlLangMatch ? attrs.replace(xmlLangRegex, newXmlLangAttr) : attrs + newXmlLangAttr;
result = result.replace(attrsMatch[0], `<html${attrs}>`);
}
}
return result;
},
};
+9 -2
View File
@@ -13,10 +13,11 @@ 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 { SUPPORTED_LANGNAMES } from '@/services/constants';
import { useSettingsStore } from './settingsStore';
import { useBookDataStore } from './bookDataStore';
import { useLibraryStore } from './libraryStore';
import { formatTitle, getBaseFilename, getPrimaryLanguage } from '@/utils/book';
interface ViewState {
/* Unique key for each book view */
@@ -137,11 +138,17 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
console.log('Loading book', key);
const { book: bookDoc } = await new DocumentLoader(file).open();
updateToc(bookDoc, config.viewSettings?.sortedTOC ?? false);
// Set the book's language for formerly imported books, newly imported books have this field set
if (!bookDoc.metadata.title) {
bookDoc.metadata.title = getBaseFilename(file.name);
}
book.sourceTitle = formatTitle(bookDoc.metadata.title);
// Correct language codes mistakenly set with language names
if (typeof bookDoc.metadata?.language === 'string') {
if (bookDoc.metadata.language in SUPPORTED_LANGNAMES) {
bookDoc.metadata.language = SUPPORTED_LANGNAMES[bookDoc.metadata.language]!;
}
}
// Set the book's language for formerly imported books, newly imported books have this field set
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
book.metadata = book.metadata ?? bookDoc.metadata;
+28 -11
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand';
import { AppService } from '@/types/system';
import { getThemeCode, ThemeCode } from '@/utils/style';
import { getSystemColorScheme } from '@/utils/bridge';
import { CustomTheme, Palette, ThemeMode } from '@/styles/themes';
import { EnvConfigType, isWebAppPlatform } from '@/services/environment';
import { SystemSettings } from '@/types/settings';
@@ -27,6 +29,7 @@ interface ThemeState {
theme: CustomTheme,
isDelete?: boolean,
) => void;
handleSystemThemeChange: (isDark: boolean) => void;
}
const getInitialThemeMode = (): ThemeMode => {
@@ -52,17 +55,6 @@ export const useThemeStore = create<ThemeState>((set, get) => {
initialThemeMode === 'dark' || (initialThemeMode === 'auto' && systemIsDarkMode);
const themeCode = getThemeCode();
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
const mode = get().themeMode;
const isDarkMode = mode === 'dark' || (mode === 'auto' && mediaQuery.matches);
set({ systemIsDarkMode: mediaQuery.matches, isDarkMode });
};
mediaQuery?.addEventListener('change', handleSystemThemeChange);
}
return {
themeMode: initialThemeMode,
themeColor: initialThemeColor,
@@ -125,5 +117,30 @@ export const useThemeStore = create<ThemeState>((set, get) => {
const appService = await envConfig.getAppService();
await appService.saveSettings(settings);
},
handleSystemThemeChange: (systemIsDarkMode) => {
const mode = get().themeMode;
const isDarkMode = mode === 'dark' || (mode === 'auto' && systemIsDarkMode);
set({ systemIsDarkMode, isDarkMode });
},
};
});
export const initSystemThemeListener = (appService: AppService) => {
if (typeof window === 'undefined' || !appService) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const update = async () => {
let systemIsDarkMode;
if (appService.isIOSApp) {
const res = await getSystemColorScheme();
systemIsDarkMode = res.colorScheme === 'dark';
} else {
systemIsDarkMode = mediaQuery.matches;
}
useThemeStore.getState().handleSystemThemeChange(systemIsDarkMode);
};
mediaQuery?.addEventListener('change', update);
document.addEventListener('visibilitychange', update);
update();
};
+6 -2
View File
@@ -5,6 +5,8 @@ export type BookNoteType = 'bookmark' | 'annotation' | 'excerpt';
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet';
export const FIXED_LAYOUT_FORMATS: Set<BookFormat> = new Set(['PDF', 'CBZ']);
export interface Book {
// if Book is a remote book we just lazy load the book content via url
url?: string;
@@ -149,8 +151,9 @@ export interface ViewConfig {
showFooter: boolean;
showRemainingTime: boolean;
showRemainingPages: boolean;
showPageNumber: boolean;
showProgressInfo: boolean;
showBarsOnScroll: boolean;
progressStyle: 'percentage' | 'fraction';
}
export interface TTSConfig {
@@ -220,7 +223,8 @@ export interface BookSearchResult {
export interface BookConfig {
bookHash?: string;
progress?: [number, number]; // [current pagenum, total pagenum], 1-based page number
location?: string;
location?: string; // CFI of the current location
xpointer?: string; // XPointer of the current location (for Koreader interoperability)
booknotes?: BookNote[];
searchConfig?: Partial<BookSearchConfig>;
viewSettings?: Partial<ViewSettings>;
+11
View File
@@ -0,0 +1,11 @@
/**
* Defines the contract for the request body that the client will send
* to our API endpoint that acts as a proxy.
*/
export interface KoSyncProxyPayload {
serverUrl: string;
endpoint: string;
method: 'GET' | 'POST' | 'PUT';
headers: Record<string, string>;
body?: unknown;
}
+1
View File
@@ -21,6 +21,7 @@ export interface DBBookConfig {
user_id: string;
book_hash: string;
location?: string;
xpointer?: string;
progress?: string;
search_config?: string;
view_settings?: string;
+12
View File
@@ -6,6 +6,9 @@ export type LibraryViewModeType = 'grid' | 'list';
export type LibrarySortByType = 'title' | 'author' | 'updated' | 'created' | 'size' | 'format';
export type LibraryCoverFitType = 'crop' | 'fit';
export type KoreaderSyncChecksumMethod = 'binary' | 'filename';
export type KoreaderSyncStrategy = 'prompt' | 'silent' | 'send' | 'receive' | 'disabled';
export interface ReadSettings {
sideBarWidth: string;
isSideBarPinned: boolean;
@@ -40,6 +43,15 @@ export interface SystemSettings {
librarySortAscending: boolean;
libraryCoverFit: LibraryCoverFitType;
koreaderSyncServerUrl: string;
koreaderSyncUsername: string;
koreaderSyncUserkey: string;
koreaderSyncDeviceId: string;
koreaderSyncDeviceName: string;
koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod;
koreaderSyncStrategy: KoreaderSyncStrategy;
koreaderSyncPercentageTolerance: number;
lastSyncedAtBooks: number;
lastSyncedAtConfigs: number;
lastSyncedAtNotes: number;
+2 -1
View File
@@ -6,6 +6,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 DeleteAction = 'cloud' | 'local' | 'both';
export interface FileSystem {
getURL(path: string): string;
@@ -57,7 +58,7 @@ export interface AppService {
overwrite?: boolean,
transient?: boolean,
): Promise<Book | null>;
deleteBook(book: Book, includingUploaded?: boolean, includingLocal?: boolean): Promise<void>;
deleteBook(book: Book, deleteAction: DeleteAction): Promise<void>;
uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void>;
downloadBook(
book: Book,
+9 -2
View File
@@ -1,9 +1,10 @@
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 { getStorageType } from './storage';
import { getDirFromLanguage } from './rtl';
import { SUPPORTED_LANGS } from '@/services/constants';
import { code6392to6391, isValidLang, normalizedLangCode } from './lang';
export const getDir = (book: Book) => {
return `${book.hash}`;
@@ -137,8 +138,14 @@ export const formatLanguage = (lang: string | string[] | undefined): string => {
: langCodeToLangName(lang || '');
};
// Should return valid ISO-639-1 language code, fallback to 'en' if not valid
export const getPrimaryLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang[0] : lang;
const primaryLang = Array.isArray(lang) ? lang[0] : lang;
if (isValidLang(primaryLang)) {
const normalizedLang = normalizedLangCode(primaryLang);
return code6392to6391(normalizedLang) || normalizedLang;
}
return 'en';
};
export const formatDate = (date: string | number | Date | null | undefined, isUTC = false) => {
+12
View File
@@ -52,6 +52,11 @@ export interface LockScreenRequest {
orientation: 'portrait' | 'landscape' | 'auto';
}
export interface GetSystemColorSchemeResponse {
colorScheme: 'light' | 'dark';
error?: string;
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
@@ -118,3 +123,10 @@ export async function lockScreenOrientation(request: LockScreenRequest): Promise
payload: request,
});
}
export async function getSystemColorScheme(): Promise<GetSystemColorSchemeResponse> {
const result = await invoke<GetSystemColorSchemeResponse>(
'plugin:native-bridge|get_system_color_scheme',
);
return result;
}
+32 -3
View File
@@ -5,22 +5,24 @@ interface DebounceOptions {
/**
* Debounces a function by waiting `delay` ms after the last call before executing it.
* If `emitLast` is false, it cancels the call instead of delaying it.
*
* @returns A debounced function with additional `flush` and `cancel` methods.
*/
export const debounce = <T extends (...args: Parameters<T>) => void | Promise<void>>(
func: T,
delay: number,
options: DebounceOptions = { emitLast: true },
): ((...args: Parameters<T>) => void) => {
): ((...args: Parameters<T>) => void) & { flush: () => void; cancel: () => void } => {
let timeout: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
return (...args: Parameters<T>): void => {
const debounced = (...args: Parameters<T>): void => {
lastArgs = args;
if (timeout) {
clearTimeout(timeout);
}
if (options.emitLast) {
lastArgs = args;
timeout = setTimeout(() => {
if (lastArgs) {
func(...(lastArgs as Parameters<T>));
@@ -35,4 +37,31 @@ export const debounce = <T extends (...args: Parameters<T>) => void | Promise<vo
}, delay);
}
};
/**
* Immediately executes the last pending debounced function call.
*/
debounced.flush = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
if (lastArgs) {
func(...(lastArgs as Parameters<T>));
lastArgs = null;
}
}
};
/**
* Cancels the pending debounced function call.
*/
debounced.cancel = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
lastArgs = null;
}
};
return debounced;
};
+45 -37
View File
@@ -65,7 +65,7 @@ export class NativeFile extends File implements ClosableFile {
#type: string = '';
static MAX_CACHE_CHUNK_SIZE = 1024 * 1024;
static MAX_CACHE_ITEMS_SIZE = 20;
static MAX_CACHE_ITEMS_SIZE = 50;
#cache: Map<number, ArrayBuffer> = new Map();
#order: number[] = [];
@@ -122,46 +122,54 @@ export class NativeFile extends File implements ClosableFile {
// exclusive reading of the end: [start, end)
async readData(start: number, end: number): Promise<ArrayBuffer> {
if (!this.#handle) {
// PDF.js workers might call readData concurrently
// make readData thread-safe by always opening and closing the file handle
const handle = await open(this.#fp, this.#baseDir ? { baseDir: this.#baseDir } : undefined);
if (!handle) {
throw new Error('File handle is not open');
}
start = Math.max(0, start);
end = Math.max(start, Math.min(this.size, end));
const size = end - start;
if (size > NativeFile.MAX_CACHE_CHUNK_SIZE) {
await this.#handle.seek(start, SeekMode.Start);
const buffer = new Uint8Array(size);
await this.#handle.read(buffer);
return buffer.buffer;
try {
start = Math.max(0, start);
end = Math.max(start, Math.min(this.size, end));
const size = end - start;
if (size > NativeFile.MAX_CACHE_CHUNK_SIZE) {
await handle.seek(start, SeekMode.Start);
const buffer = new Uint8Array(size);
await handle.read(buffer);
return buffer.buffer;
}
const cachedChunkStart = Array.from(this.#cache.keys()).find((chunkStart) => {
const buffer = this.#cache.get(chunkStart)!;
return start >= chunkStart && end <= chunkStart + buffer.byteLength;
});
if (cachedChunkStart !== undefined) {
this.#updateAccessOrder(cachedChunkStart);
const buffer = this.#cache.get(cachedChunkStart)!;
const offset = start - cachedChunkStart;
return buffer.slice(offset, offset + size);
}
const chunkStart = Math.max(0, start - 1024);
const chunkEnd = Math.min(this.size, start + NativeFile.MAX_CACHE_CHUNK_SIZE);
const chunkSize = chunkEnd - chunkStart;
await handle.seek(chunkStart, SeekMode.Start);
const buffer = new Uint8Array(chunkSize);
await handle.read(buffer);
this.#cache.set(chunkStart, buffer.buffer);
this.#updateAccessOrder(chunkStart);
this.#ensureCacheSize();
const offset = start - chunkStart;
return buffer.buffer.slice(offset, offset + size);
} finally {
await handle.close();
}
const cachedChunkStart = Array.from(this.#cache.keys()).find((chunkStart) => {
const buffer = this.#cache.get(chunkStart)!;
return start >= chunkStart && end <= chunkStart + buffer.byteLength;
});
if (cachedChunkStart !== undefined) {
this.#updateAccessOrder(cachedChunkStart);
const buffer = this.#cache.get(cachedChunkStart)!;
const offset = start - cachedChunkStart;
return buffer.slice(offset, offset + size);
}
const chunkStart = Math.max(0, start - 1024);
const chunkEnd = Math.min(this.size, chunkStart + NativeFile.MAX_CACHE_CHUNK_SIZE);
const chunkSize = chunkEnd - chunkStart;
await this.#handle.seek(chunkStart, SeekMode.Start);
const buffer = new Uint8Array(chunkSize);
await this.#handle.read(buffer);
this.#cache.set(chunkStart, buffer.buffer);
this.#updateAccessOrder(chunkStart);
this.#ensureCacheSize();
const offset = start - chunkStart;
return buffer.buffer.slice(offset, offset + size);
}
#updateAccessOrder(chunkStart: number) {
+17 -3
View File
@@ -8,8 +8,8 @@ export const isCJKStr = (str: string) => {
export const isCJKLang = (lang: string | null | undefined): boolean => {
if (!lang) return false;
const normalizedLang = lang.split('-')[0]!.toLowerCase();
return ['zh', 'ja', 'ko'].includes(normalizedLang);
const normalizedLang = normalizedLangCode(lang);
return ['zh', 'ja', 'ko', 'zho', 'jpn', 'kor'].includes(normalizedLang);
};
export const normalizeToFullLang = (langCode: string): string => {
@@ -30,6 +30,19 @@ export const normalizeToFullLang = (langCode: string): string => {
ru: 'ru-RU',
uk: 'uk-UA',
th: 'th-TH',
no: 'no-NO',
sv: 'sv-SE',
fi: 'fi-FI',
da: 'da-DK',
cs: 'cs-CZ',
hu: 'hu-HU',
ro: 'ro-RO',
bg: 'bg-BG',
hr: 'hr-HR',
lt: 'lt-LT',
sl: 'sl-SI',
sk: 'sk-SK',
bo: 'bo-CN',
zh: 'zh-Hans',
'zh-cn': 'zh-Hans',
'zh-tw': 'zh-Hant',
@@ -72,7 +85,8 @@ export const isSameLang = (lang1?: string | null, lang2?: string | null): boolea
export const isValidLang = (lang?: string) => {
if (!lang) return false;
const code = lang.split('-')[0]!.toLowerCase();
if (typeof lang !== 'string') return false;
const code = normalizedLangCode(lang);
return iso6392.some((l) => l.iso6391 === code || l.iso6392B === code);
};
+7
View File
@@ -83,3 +83,10 @@ export const navigateToLibrary = (
export const redirectToLibrary = () => {
redirect('/library');
};
export const navigateToResetPassword = (router: ReturnType<typeof useRouter>) => {
const pathname = window.location.pathname;
const search = window.location.search;
const currentPath = pathname !== '/auth' ? pathname + search : '/';
router.push(`/auth/recovery?redirect=${encodeURIComponent(currentPath)}`);
};
+13
View File
@@ -0,0 +1,13 @@
export function formatReadingProgress(
current: number | undefined,
total: number | undefined,
template: string,
): string {
if (current === undefined || total === undefined || total <= 0 || current < 0) {
return '';
}
return template
.replace('{current}', String(current + 1))
.replace('{total}', String(total))
.replace('{percent}', (((current + 1) / total) * 100).toFixed(1));
}
+17 -6
View File
@@ -82,6 +82,9 @@ 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;
@@ -117,7 +120,7 @@ const getColorStyles = (
background-color: var(--theme-bg-color, transparent);
background: var(--background-set, none);
}
div, p, font, h1, h2, h3, h4, h5, h6 {
section, div, p, font, h1, h2, h3, h4, h5, h6 {
${overrideColor ? `background-color: ${bg} !important;` : ''}
${overrideColor ? `color: ${fg} !important;` : ''}
}
@@ -138,6 +141,13 @@ const getColorStyles = (
${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''}
${!isDarkMode && overrideColor ? 'mix-blend-mode: multiply;' : ''}
}
/* horizontal rule */
*:has(hr) {
background-color: ${bg};
}
hr {
mix-blend-mode: multiply;
}
/* inline images */
p img, span img, sup img {
mix-blend-mode: ${isDarkMode ? 'screen' : 'multiply'};
@@ -232,7 +242,7 @@ const getLayoutStyles = (
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em ${overrideLayout ? '!important' : ''};
${justify ? `text-align: justify ${overrideLayout ? '!important' : ''};` : ''}
${!justify && overrideLayout ? 'text-align: unset !important;' : ''};
${!justify && overrideLayout ? 'text-align: initial !important;' : ''};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
hyphens: ${hyphenate ? 'auto' : 'manual'};
-webkit-hyphenate-limit-before: 3;
@@ -521,10 +531,11 @@ export const transformStylesheet = (vw: number, vh: number, css: string) => {
})
.replace(/(\d*\.?\d+)vw/gi, (_, d) => (parseFloat(d) * vw) / 100 + 'px')
.replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * vh) / 100 + 'px')
.replace(/[\s;]color\s*:\s*black/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*#000000/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*#000/gi, 'color: var(--theme-fg-color)')
.replace(/[\s;]color\s*:\s*rgb\(0,\s*0,\s*0\)/gi, 'color: var(--theme-fg-color)');
.replace(/([\s;])font-family\s*:\s*monospace/gi, '$1font-family: var(--monospace)')
.replace(/([\s;])color\s*:\s*black/gi, '$1color: var(--theme-fg-color)')
.replace(/([\s;])color\s*:\s*#000000/gi, '$1color: var(--theme-fg-color)')
.replace(/([\s;])color\s*:\s*#000/gi, '$1color: var(--theme-fg-color)')
.replace(/([\s;])color\s*:\s*rgb\(0,\s*0,\s*0\)/gi, '$1color: var(--theme-fg-color)');
return css;
};
+5 -2
View File
@@ -11,13 +11,14 @@ import { DBBookConfig, DBBook, DBBookNote } from '@/types/records';
import { sanitizeString } from './sanitize';
export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DBBookConfig => {
const { bookHash, progress, location, searchConfig, viewSettings, updatedAt } =
const { bookHash, progress, location, xpointer, searchConfig, viewSettings, updatedAt } =
bookConfig as BookConfig;
return {
user_id: userId,
book_hash: bookHash!,
location: location,
xpointer: xpointer,
progress: progress && JSON.stringify(progress),
search_config: searchConfig && JSON.stringify(searchConfig),
view_settings: viewSettings && JSON.stringify(viewSettings),
@@ -26,10 +27,12 @@ export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DB
};
export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfig => {
const { book_hash, progress, location, search_config, view_settings, updated_at } = dbBookConfig;
const { book_hash, progress, location, xpointer, search_config, view_settings, updated_at } =
dbBookConfig;
return {
bookHash: book_hash,
location,
xpointer,
progress: progress && JSON.parse(progress),
searchConfig: search_config && JSON.parse(search_config),
viewSettings: view_settings && JSON.parse(view_settings),
+66
View File
@@ -0,0 +1,66 @@
import { createSupabaseAdminClient } from '@/utils/supabase';
export const USAGE_TYPES = {
TRANSLATION_CHARS: 'translation_chars',
} as const;
export const QUOTA_TYPES = {
DAILY: 'daily',
MONTHLY: 'monthly',
YEARLY: 'yearly',
} as const;
export class UsageStatsManager {
static async trackUsage(
userId: string,
usageType: string,
increment: number = 1,
metadata: Record<string, string | number> = {},
): Promise<number> {
try {
const supabase = createSupabaseAdminClient();
const { data, error } = await supabase.rpc('increment_daily_usage', {
p_user_id: userId,
p_usage_type: usageType,
p_usage_date: new Date().toISOString().split('T')[0],
p_increment: increment,
p_metadata: metadata,
});
if (error) {
console.error('Usage tracking error:', error);
return 0;
}
return data || 0;
} catch (error) {
console.error('Usage tracking failed:', error);
return 0;
}
}
static async getCurrentUsage(
userId: string,
usageType: string,
period: 'daily' | 'monthly' = 'daily',
): Promise<number> {
try {
const supabase = createSupabaseAdminClient();
const { data, error } = await supabase.rpc('get_current_usage', {
p_user_id: userId,
p_usage_type: usageType,
p_period: period,
});
if (error) {
console.error('Get current usage error:', error);
return 0;
}
return data || 0;
} catch (error) {
console.error('Get current usage failed:', error);
return 0;
}
}
}
+541
View File
@@ -0,0 +1,541 @@
/**
* Converter between EPUB CFI and CREngine XPointer
* Converts between Readest (foliate-js) CFI format and KOReader CREngine XPointer format
*/
import { BookDoc } from '@/libs/document';
import { parse, fake, collapse, fromRange, toRange, toElement } from 'foliate-js/epubcfi.js';
type XPointer = {
xpointer: string;
pos0?: string;
pos1?: string;
};
export class XCFI {
private document: Document;
private spineItemIndex: number;
constructor(htmlDocument: Document, spineIndex: number = 0) {
this.document = htmlDocument;
this.spineItemIndex = spineIndex;
}
static extractSpineIndex(cfiOrXPath: string): number {
try {
if (cfiOrXPath.startsWith('epubcfi(')) {
const collapsed = collapse(parse(cfiOrXPath));
const spineStep = collapsed[0]?.[1]?.index;
if (spineStep === undefined) {
throw new Error('Cannot extract spine index from CFI');
}
// Convert CFI spine step to 0-based index
// CFI uses even numbers starting from 2: 2, 4, 6, 8, ...
// Convert to 0-based: (step - 2) / 2 = 0, 1, 2, 3, ...
return Math.floor((spineStep - 2) / 2);
} else if (cfiOrXPath.startsWith('/body/DocFragment[')) {
const match = cfiOrXPath.match(/DocFragment\[(\d+)\]/);
if (match) {
return parseInt(match[1]!, 10) - 1;
}
throw new Error('Cannot extract spine index from XPath');
} else {
throw new Error('Unsupported format for spine index extraction');
}
} catch (error) {
throw new Error(`Cannot extract spine index from CFI/XPointer: ${cfiOrXPath} - ${error}`);
}
}
xPointerToCFI(startXPointer: string, endXPointer?: string): string {
try {
if (endXPointer) {
return this.convertRangeXPointerToCFI(startXPointer, endXPointer);
}
return this.convertPointXPointerToCFI(startXPointer);
} catch (error) {
throw new Error(`Failed to convert XPointer ${startXPointer}: ${error}`);
}
}
cfiToXPointer(cfi: string): XPointer {
try {
const parts = parse(cfi);
if (parts.parent) {
const index = fake.toIndex(parts.parent.shift()); // Remove the spine step
if (index !== this.spineItemIndex) {
throw new Error(
`CFI spine index ${index} does not match converter spine index ${this.spineItemIndex}`,
);
}
const range = toRange(this.document, parts);
const startXPointer = this.rangePointToXPointer(range.startContainer, range.startOffset);
const endXPointer = this.rangePointToXPointer(range.endContainer, range.endOffset);
return {
xpointer: startXPointer,
pos0: startXPointer,
pos1: endXPointer,
};
}
const collapsed = collapse(parts);
const index = fake.toIndex(parts.shift());
if (index !== this.spineItemIndex) {
throw new Error(
`CFI spine index ${index} does not match converter spine index ${this.spineItemIndex}`,
);
}
const element = toElement(this.document, parts[0]) as Element;
if (!element) {
throw new Error(`Element not found for CFI: ${cfi}`);
}
const lastPart =
collapsed[collapsed.length - 1]?.[collapsed[collapsed.length - 1].length - 1];
const textOffset = lastPart?.offset;
const xpointer =
textOffset !== undefined
? this.handleTextOffset(element, textOffset)
: this.buildXPointerPath(element);
return { xpointer };
} catch (error) {
throw new Error(`Failed to convert CFI ${cfi}: ${error}`);
}
}
validateCFI(cfi: string): boolean {
try {
parse(cfi);
this.cfiToXPointer(cfi);
return true;
} catch {
return false;
}
}
validateXPointer(xpointer: string, pos1?: string): boolean {
try {
this.xPointerToCFI(xpointer, pos1);
return true;
} catch {
return false;
}
}
/**
* Convert a single point XPointer to CFI
*/
private convertPointXPointerToCFI(xpointer: string): string {
const { element, textOffset } = this.parseXPointer(xpointer);
const range = this.document.createRange();
if (textOffset !== undefined) {
const textNode = this.findTextNodeAtOffset(element, textOffset);
if (textNode) {
range.setStart(textNode.node, textNode.offset);
range.setEnd(textNode.node, textNode.offset);
} else {
// Fallback to element positioning
range.setStart(element, 0);
range.setEnd(element, 0);
}
} else {
range.setStart(element, 0);
range.setEnd(element, 0);
}
const cfi = fromRange(range);
return this.adjustSpineIndex(cfi);
}
private convertRangeXPointerToCFI(startXPointer: string, endXPointer: string): string {
const startInfo = this.parseXPointer(startXPointer);
const endInfo = this.parseXPointer(endXPointer);
const range = this.document.createRange();
if (startInfo.textOffset !== undefined) {
const startTextNode = this.findTextNodeAtOffset(startInfo.element, startInfo.textOffset);
if (startTextNode) {
range.setStart(startTextNode.node, startTextNode.offset);
} else {
range.setStart(startInfo.element, 0);
}
} else {
range.setStart(startInfo.element, 0);
}
if (endInfo.textOffset !== undefined) {
const endTextNode = this.findTextNodeAtOffset(endInfo.element, endInfo.textOffset);
if (endTextNode) {
range.setEnd(endTextNode.node, endTextNode.offset);
} else {
range.setEnd(endInfo.element, 0);
}
} else {
range.setEnd(endInfo.element, 0);
}
const cfi = fromRange(range);
return this.adjustSpineIndex(cfi);
}
/**
* Parse XPointer string to extract element and text offset
*/
private parseXPointer(xpointer: string): { element: Element; textOffset?: number } {
const textOffsetMatch = xpointer.match(/\/text\(\)\.(\d+)$/);
const textOffset = textOffsetMatch ? parseInt(textOffsetMatch[1]!, 10) : undefined;
const elementPath =
textOffset !== undefined ? xpointer.replace(/\/text\(\)\.\d+$/, '') : xpointer;
const element = this.resolveXPointerPath(elementPath);
if (!element) {
throw new Error(`Cannot resolve XPointer path: ${elementPath}`);
}
return { element, textOffset };
}
private resolveXPointerPath(path: string): Element | null {
const pathMatch = path.match(/^\/body\/DocFragment\[\d+\]\/body(.*)$/);
if (!pathMatch) {
throw new Error(`Invalid XPointer format: ${path}`);
}
const elementPath = pathMatch[1]!;
let current: Element = this.document.body;
if (!elementPath || elementPath === '') {
return current;
}
const segments = elementPath.split('/').filter(Boolean);
for (const segment of segments) {
// Match both formats: tag[index] or just tag
const segmentWithIndexMatch = segment.match(/^(\w+)\[(\d+)\]$/);
const segmentWithoutIndexMatch = segment.match(/^(\w+)$/);
let tagName: string;
let index: number;
if (segmentWithIndexMatch) {
// Format: tag[index]
const [, tag, indexStr] = segmentWithIndexMatch;
tagName = tag!;
index = parseInt(indexStr!, 10);
} else if (segmentWithoutIndexMatch) {
// Format: tag (implicit index 0)
const [, tag] = segmentWithoutIndexMatch;
tagName = tag!;
index = 0;
} else {
throw new Error(`Invalid XPointer segment: ${segment}`);
}
// Find child elements with matching tag name
const children = Array.from(current.children).filter(
(child) => child.tagName.toLowerCase() === tagName?.toLowerCase(),
);
if (index >= children.length) {
throw new Error(`Element index ${index} out of bounds for tag ${tagName}`);
}
current = children[index]!;
}
return current;
}
/**
* Find text node and offset within element based on cumulative character offset
*/
private findTextNodeAtOffset(
element: Element,
offset: number,
): { node: Text; offset: number } | null {
const textNodes: Text[] = [];
this.collectTextNodes(element, textNodes);
let currentOffset = 0;
for (const textNode of textNodes) {
const nodeText = textNode.textContent || '';
const nodeLength = nodeText.length;
if (currentOffset + nodeLength >= offset) {
return {
node: textNode,
offset: offset - currentOffset,
};
}
currentOffset += nodeLength;
}
// If offset is beyond all text, return the last text node at its end
if (textNodes.length > 0) {
const lastNode = textNodes[textNodes.length - 1]!;
return {
node: lastNode,
offset: (lastNode.textContent || '').length,
};
}
return null;
}
private adjustSpineIndex(cfi: string): string {
const cfiMatch = cfi.match(/^epubcfi\((.+)\)$/);
if (!cfiMatch) {
throw new Error(`Invalid CFI format: ${cfi}`);
}
const innerCfi = cfiMatch[1]!;
const spineStep = (this.spineItemIndex + 1) * 2; // Convert 0-based to CFI format
if (innerCfi.match(/^\/6\/\d+!/)) {
const adjustedInner = innerCfi.replace(/^\/6\/\d+!/, `/6/${spineStep}!`);
return `epubcfi(${adjustedInner})`;
} else {
const adjustedInner = `/6/${spineStep}!${innerCfi}`;
return `epubcfi(${adjustedInner})`;
}
}
/**
* Convert a range point (container + offset) to XPointer
*/
private rangePointToXPointer(container: Node, offset: number): string {
if (container.nodeType === Node.TEXT_NODE) {
// For text nodes, find the containing element
const element = container.parentElement || this.document.documentElement;
return this.handleTextOffsetInElement(element, container as Text, offset);
} else if (container.nodeType === Node.ELEMENT_NODE) {
const element = container as Element;
if (offset === 0) {
if (element.childNodes.length > 0) {
const firstChild = element.childNodes[0] as Element;
if (firstChild.nodeType === Node.ELEMENT_NODE) {
return this.buildXPointerPath(element.childNodes[0] as Element);
}
}
return this.buildXPointerPath(element);
} else {
// Offset points to a child node
const childNodes = Array.from(element.childNodes);
const targetChild = childNodes[offset - 1] || childNodes[childNodes.length - 1];
if (targetChild?.nodeType === Node.ELEMENT_NODE) {
return this.buildXPointerPath(targetChild as Element);
} else if (targetChild?.nodeType === Node.TEXT_NODE) {
return this.handleTextOffsetInElement(
element,
targetChild as Text,
(targetChild as Text).textContent?.length || 0,
);
} else {
return this.buildXPointerPath(element);
}
}
} else {
// Fallback to document element
return this.buildXPointerPath(this.document.documentElement);
}
}
/**
* Build XPointer path from DOM element
*/
private buildXPointerPath(targetElement: Element): string {
const pathParts: string[] = [];
let current: Element | null = targetElement;
// Build path from target back to root
while (current && current !== this.document.documentElement) {
const parent: Element | null = current.parentElement;
if (!parent) break;
const tagName = current.tagName.toLowerCase();
// Count preceding siblings with same tag name (0-based for CREngine)
let siblingIndex = 0;
let totalSameTagSiblings = 0;
for (const sibling of Array.from(parent.children)) {
if (sibling.tagName.toLowerCase() === tagName) {
if (sibling === current) {
siblingIndex = totalSameTagSiblings;
}
totalSameTagSiblings++;
}
}
// Format as tag[index] (0-based for CREngine)
// Omit [0] if there's only one element with this tag name
if (totalSameTagSiblings === 1) {
pathParts.unshift(tagName);
} else {
pathParts.unshift(`${tagName}[${siblingIndex}]`);
}
current = parent;
}
let xpointer = `/body/DocFragment[${this.spineItemIndex + 1}]`;
if (pathParts.length > 0 && pathParts[0]!.startsWith('body')) {
pathParts.shift();
}
xpointer += '/body';
if (pathParts.length > 0) {
xpointer += '/' + pathParts.join('/');
}
return xpointer;
}
/**
* Handle text offset within an element by finding character position
*/
private handleTextOffset(element: Element, cfiOffset: number): string {
const textNodes: Text[] = [];
this.collectTextNodes(element, textNodes);
let totalChars = 0;
let targetTextNode: Text | null = null;
let offsetInNode = 0;
for (const textNode of textNodes) {
const nodeText = textNode.textContent || '';
const nodeLength = nodeText.length;
if (totalChars + nodeLength >= cfiOffset) {
targetTextNode = textNode;
offsetInNode = cfiOffset - totalChars;
break;
}
totalChars += nodeLength;
}
if (!targetTextNode) {
// Offset beyond text content, use element end
return this.buildXPointerPath(element);
}
// Find the containing element for this text node
let textParent = targetTextNode.parentElement;
while (textParent && !this.isSignificantElement(textParent)) {
textParent = textParent.parentElement;
}
if (!textParent) {
textParent = element as HTMLElement;
}
const basePath = this.buildXPointerPath(textParent);
return `${basePath}/text().${offsetInNode}`;
}
/**
* Handle text offset for a specific text node within an element
*/
private handleTextOffsetInElement(element: Element, textNode: Text, offset: number): string {
// Find all text nodes in the element to calculate cumulative offset
const textNodes: Text[] = [];
this.collectTextNodes(element, textNodes);
let cumulativeOffset = 0;
for (const node of textNodes) {
if (node === textNode) {
cumulativeOffset += offset;
break;
}
cumulativeOffset += (node.textContent || '').length;
}
return this.handleTextOffset(element, cumulativeOffset);
}
/**
* Collect all text nodes in document order
*/
private collectTextNodes(element: Element, textNodes: Text[]): void {
for (const child of Array.from(element.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent || '';
if (text.length > 0) {
textNodes.push(child as Text);
}
} else if (child.nodeType === Node.ELEMENT_NODE) {
this.collectTextNodes(child as Element, textNodes);
}
}
}
/**
* Check if an element is significant for XPointer path building
*/
private isSignificantElement(element: Element): boolean {
const tagName = element.tagName.toLowerCase();
// Skip inline formatting elements that don't affect structure
const inlineElements = new Set([
'span',
'em',
'strong',
'i',
'b',
'u',
'small',
'mark',
'sup',
'sub',
]);
return !inlineElements.has(tagName);
}
}
export const getCFIFromXPointer = async (
xpointer: string,
doc?: Document,
index?: number,
bookDoc?: BookDoc,
) => {
const xSpineIndex = XCFI.extractSpineIndex(xpointer);
let converter: XCFI;
if (index === xSpineIndex && doc) {
converter = new XCFI(doc, index || 0);
} else {
const doc = await bookDoc?.sections?.[xSpineIndex]?.createDocument();
if (!doc) throw new Error('Failed to load document for XPointer conversion.');
converter = new XCFI(doc, xSpineIndex || 0);
}
const cfi = converter.xPointerToCFI(xpointer);
return cfi;
};
export const getXPointerFromCFI = async (
cfi: string,
doc?: Document,
index?: number,
bookDoc?: BookDoc,
): Promise<XPointer> => {
const xSpineIndex = XCFI.extractSpineIndex(cfi);
let converter: XCFI;
if (index === xSpineIndex && doc) {
converter = new XCFI(doc, index || 0);
} else {
const doc = await bookDoc?.sections?.[xSpineIndex]?.createDocument();
if (!doc) throw new Error('Failed to load document for CFI conversion.');
converter = new XCFI(doc, xSpineIndex || 0);
}
const xpointer = converter.cfiToXPointer(cfi);
return xpointer;
};
+6
View File
@@ -0,0 +1,6 @@
local _ = require("gettext")
return {
name = "readest",
fullname = _("Readest sync"),
description = _([[Keeps your KOReader and Readest devices in sync.]]),
}
+553
View File
@@ -0,0 +1,553 @@
local Device = require("device")
local Event = require("ui/event")
local InfoMessage = require("ui/widget/infomessage")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local MultiInputDialog = require("ui/widget/multiinputdialog")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local time = require("ui/time")
local util = require("util")
local sha2 = require("ffi/sha2")
local T = require("ffi/util").template
local _ = require("gettext")
local ReadestSync = WidgetContainer:new{
name = "readest",
title = _("Readest Sync"),
settings = nil,
}
local API_CALL_DEBOUNCE_DELAY = 30
local SUPABAE_ANON_KEY_BASE64 = "ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
ReadestSync.default_settings = {
supabase_url = "https://readest.supabase.co",
supabase_anon_key = sha2.base64_to_bin(SUPABAE_ANON_KEY_BASE64),
auto_sync = false,
user_email = nil,
user_name = nil,
user_id = nil,
access_token = nil,
refresh_token = nil,
expires_at = nil,
expires_in = nil,
last_sync_at = nil,
}
function ReadestSync:init()
self.last_sync_timestamp = 0
self.settings = G_reader_settings:readSetting("readest_sync", self.default_settings)
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
end
function ReadestSync:onDispatcherRegisterActions()
--
end
function ReadestSync:needsLogin()
return not self.settings.access_token or not self.settings.expires_at
or self.settings.expires_at < os.time() + 60
end
function ReadestSync:tryRefreshToken()
if self.settings.refresh_token and self.settings.expires_at
and self.settings.expires_at < os.time() - self.settings.expires_in / 2 then
local client = self:getSupabaseAuthClient()
client:refresh_token(self.settings.refresh_token, function(success, response)
if success then
self.settings.access_token = response.access_token
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)
else
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
end
end)
end
end
function ReadestSync:addToMainMenu(menu_items)
menu_items.readest_sync = {
sorting_hint = "tools",
text = _("Readest Sync"),
sub_item_table = {
{
text_func = function()
return self:needsLogin() and _("Log in Readest Account")
or _("Log out as ") .. (self.settings.user_name or "")
end,
callback_func = function()
if self:needsLogin() then
return function(menu)
self:login(menu)
end
else
return function(menu)
self:logout(menu)
end
end
end,
separator = true,
},
{
text = _("Auto sync book configs"),
checked_func = function() return self.settings.auto_sync end,
callback = function()
self.settings.auto_sync = not self.settings.auto_sync
if self.settings.auto_sync then
self:pullBookConfig(false)
end
end,
separator = true,
},
{
text = _("Push book config now"),
enabled_func = function()
return self.settings.access_token ~= nil
end,
callback = function()
self:pushBookConfig(true)
end,
},
{
text = _("Pull book config now"),
enabled_func = function()
return self.settings.access_token ~= nil
end,
callback = function()
self:pullBookConfig(true)
end,
},
}
}
end
function ReadestSync:getSupabaseAuthClient()
if not self.settings.supabase_url or not self.settings.supabase_anon_key then
return nil
end
local SupabaseAuthClient = require("supabaseauth")
return SupabaseAuthClient:new{
service_spec = self.path .. "/supabase-auth-api.json",
custom_url = self.settings.supabase_url .. "/auth/v1/",
api_key = self.settings.supabase_anon_key,
}
end
function ReadestSync:getReadestSyncClient()
if not self.settings.access_token or not self.settings.expires_at or self.settings.expires_at < os.time() then
return nil
end
local ReadestSyncClient = require("readestsync")
return ReadestSyncClient:new{
service_spec = self.path .. "/readest-sync-api.json",
access_token = self.settings.access_token,
}
end
function ReadestSync:login(menu)
if NetworkMgr:willRerunWhenOnline(function() self:login(menu) end) then
return
end
local dialog
dialog = MultiInputDialog:new{
title = self.title,
fields = {
{
text = self.settings.user_email,
hint = "email@example.com",
},
{
hint = "password",
text_type = "password",
},
},
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(dialog)
end,
},
{
text = _("Login"),
callback = function()
local email, password = unpack(dialog:getFields())
email = util.trim(email)
if email == "" or password == "" then
UIManager:show(InfoMessage:new{
text = _("Please enter both email and password"),
timeout = 2,
})
return
end
UIManager:close(dialog)
self:doLogin(email, password, menu)
end,
},
},
},
}
UIManager:show(dialog)
dialog:onShowKeyboard()
end
function ReadestSync:doLogin(email, password, menu)
local client = self:getSupabaseAuthClient()
if not client then
UIManager:show(InfoMessage:new{
text = _("Please configure Supabase URL and API key first"),
timeout = 3,
})
return
end
UIManager:show(InfoMessage:new{
text = _("Logging in..."),
timeout = 1,
})
Device:setIgnoreInput(true)
local success, response = client:sign_in_password(email, password)
Device:setIgnoreInput(false)
if success then
self.settings.user_email = email
self.settings.user_id = response.user.id
self.settings.user_name = response.user.user_metadata.user_name or email
self.settings.access_token = response.access_token
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)
if menu then
menu:updateItems()
end
UIManager:show(InfoMessage:new{
text = _("Successfully logged in to Readest"),
timeout = 3,
})
else
UIManager:show(InfoMessage:new{
text = _("Login failed: ") .. (response.message or "Unknown error"),
timeout = 3,
})
end
end
function ReadestSync:logout(menu)
if self.access_token then
local client = self:getSupabaseAuthClient()
if client then
client:sign_out(self.settings.access_token, function(success, response)
logger.dbg("ReadestSync: Sign out result:", success)
end)
end
end
self.settings.access_token = nil
self.settings.refresh_token = nil
self.settings.expires_at = nil
self.settings.expires_in = nil
G_reader_settings:writeSetting("readest_sync", self.settings)
if menu then
menu:updateItems()
end
UIManager:show(InfoMessage:new{
text = _("Logged out from Readest Sync"),
timeout = 2,
})
end
function ReadestSync:getDocumentIdentifier()
return self.ui.doc_settings:readSetting("partial_md5_checksum")
end
function ReadestSync:showSyncedMessage()
UIManager:show(InfoMessage:new{
text = _("Progress has been synchronized."),
timeout = 3,
})
end
function ReadestSync:applyBookConfig(config)
logger.dbg("ReadestSync: Applying book config:", config)
local xpointer = config.xpointer
local progress = config.progress
local has_pages = self.ui.document.info.has_pages
-- Check if it's the bracket format: [page,total_pages]
local progress_pattern = "^%[(%d+),(%d+)%]$"
if has_pages and progress then
local page, total_pages = progress:match(progress_pattern)
local current_page = self.ui:getCurrentPage()
local new_page = tonumber(page)
if new_page > current_page then
self.ui.link:addCurrentLocationToStack()
self.ui:handleEvent(Event:new("GotoPage", new_page))
self:showSyncedMessage()
end
end
if not has_pages and xpointer then
local last_xpointer = self.ui.rolling:getLastProgress()
local working_xpointer = xpointer
local cmp_result = self.document:compareXPointers(last_xpointer, working_xpointer)
-- FIXME: Crengine is not very good at comparing XPointers, so we need to reduce the path
while cmp_result == nil and working_xpointer do
local last_slash_pos = working_xpointer:match("^.*()/")
if last_slash_pos and last_slash_pos > 1 then
working_xpointer = working_xpointer:sub(1, last_slash_pos - 1)
cmp_result = self.document:compareXPointers(last_xpointer, working_xpointer)
else
break
end
end
if cmp_result > 0 then
self.ui.link:addCurrentLocationToStack()
self.ui:handleEvent(Event:new("GotoXPointer", working_xpointer))
self:showSyncedMessage()
end
end
end
function ReadestSync:getCurrentBookConfig()
local book_hash = self:getDocumentIdentifier()
if not book_hash then
UIManager:show(InfoMessage:new{
text = _("Cannot identify the current book"),
timeout = 2,
})
return nil
end
local config = {
bookHash = book_hash,
progress = "",
xpointer = "",
updatedAt = os.time() * 1000,
}
local current_page = self.ui:getCurrentPage()
local page_count = self.ui.document:getPageCount()
config.progress = {current_page, page_count}
if not self.ui.document.info.has_pages then
config.xpointer = self.ui.rolling:getLastProgress()
end
return config
end
function ReadestSync:pushBookConfig(interactive)
if not self.settings.access_token or not self.settings.user_id then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Please login first"),
timeout = 2,
})
end
return
end
local now = os.time()
if not interactive and now - self.last_sync_timestamp <= API_CALL_DEBOUNCE_DELAY then
logger.dbg("ReadestSync: Debouncing push request")
return
end
local config = self:getCurrentBookConfig()
if not config then return end
if NetworkMgr:willRerunWhenOnline(function() self:pushBookConfig(interactive) end) then
return
end
local client = self:getReadestSyncClient()
if not client then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Please configure Readest settings first"),
timeout = 3,
})
end
return
end
self:tryRefreshToken()
if interactive then
UIManager:show(InfoMessage:new{
text = _("Pushing book config..."),
timeout = 1,
})
end
local payload = {
books = {},
notes = {},
configs = { config }
}
client:pushChanges(
payload,
function(success, response)
if interactive then
if success then
UIManager:show(InfoMessage:new{
text = _("Book config pushed successfully"),
timeout = 2,
})
else
UIManager:show(InfoMessage:new{
text = _("Failed to push book config"),
timeout = 2,
})
end
end
if success then
self.last_sync_timestamp = os.time()
end
end
)
end
function ReadestSync:pullBookConfig(interactive)
if not self.settings.access_token or not self.settings.user_id then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Please login first"),
timeout = 2,
})
end
return
end
local book_hash = self:getDocumentIdentifier()
if not book_hash then return end
if NetworkMgr:willRerunWhenOnline(function() self:pullBookConfig(interactive) end) then
return
end
local client = self:getReadestSyncClient()
if not client then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Please configure Readest settings first"),
timeout = 3,
})
end
return
end
self:tryRefreshToken()
if interactive then
UIManager:show(InfoMessage:new{
text = _("Pulling book config..."),
timeout = 1,
})
end
client:pullChanges(
{
since = 0,
type = "configs",
book = book_hash,
},
function(success, response)
if not success then
if response and response.error == "Not authenticated" then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Authentication failed, please login again"),
timeout = 2,
})
end
self:logout()
return
end
if interactive then
UIManager:show(InfoMessage:new{
text = _("Failed to pull book config"),
timeout = 2,
})
end
return
end
local data = response.configs
if data and #data > 0 then
local config = data[1]
if config then
self:applyBookConfig(config)
if interactive then
UIManager:show(InfoMessage:new{
text = _("Book config synchronized"),
timeout = 2,
})
end
return
end
end
if interactive then
UIManager:show(InfoMessage:new{
text = _("No saved config found for this book"),
timeout = 2,
})
end
end
)
end
function ReadestSync:onReaderReady()
if self.settings.auto_sync and self.settings.access_token then
UIManager:nextTick(function()
self:pullBookConfig(false)
end)
end
end
function ReadestSync:onCloseDocument()
if self.settings.auto_sync and self.settings.access_token then
NetworkMgr:goOnlineToRun(function()
self:pushBookConfig(false)
end)
end
end
function ReadestSync:onPageUpdate(page)
if self.settings.auto_sync and self.settings.access_token and page then
if self.delayed_push_task then
UIManager:unschedule(self.delayed_push_task)
end
self.delayed_push_task = function()
self:pushBookConfig(false)
end
UIManager:scheduleIn(5, self.delayed_push_task)
end
end
function ReadestSync:onCloseWidget()
if self.delayed_push_task then
UIManager:unschedule(self.delayed_push_task)
self.delayed_push_task = nil
end
end
return ReadestSync
@@ -0,0 +1,19 @@
{
"base_url": "http://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]
},
"pushChanges": {
"path": "/sync",
"method": "POST",
"required_params": ["books", "notes", "configs"],
"payload": ["books", "notes", "configs"],
"expected_status": [200, 201, 400, 401, 403]
}
}
}
+123
View File
@@ -0,0 +1,123 @@
local UIManager = require("ui/uimanager")
local logger = require("logger")
local socketutil = require("socketutil")
-- Sync operation timeouts
local SYNC_TIMEOUTS = { 5, 10 }
local ReadestSyncClient = {
service_spec = nil,
access_token = nil,
}
function ReadestSyncClient:new(o)
if o == nil then o = {} end
setmetatable(o, self)
self.__index = self
if o.init then o:init() end
return o
end
function ReadestSyncClient:init()
local Spore = require("Spore")
self.client = Spore.new_from_spec(self.service_spec)
-- Readest API headers middleware
package.loaded["Spore.Middleware.ReadestHeaders"] = {}
require("Spore.Middleware.ReadestHeaders").call = function(args, req)
req.headers["content-type"] = "application/json"
req.headers["accept"] = "application/json"
end
-- Readest Bearer token auth middleware
package.loaded["Spore.Middleware.ReadestAuth"] = {}
require("Spore.Middleware.ReadestAuth").call = function(args, req)
if self.access_token then
req.headers["authorization"] = "Bearer " .. self.access_token
else
logger.err("ReadestSyncClient:access_token is not set, cannot authenticate")
return false, "Access token is required for Readest API"
end
end
package.loaded["Spore.Middleware.AsyncHTTP"] = {}
require("Spore.Middleware.AsyncHTTP").call = function(args, req)
-- disable async http if Turbo looper is missing
if not UIManager.looper then return end
req:finalize()
local result
require("httpclient"):new():request({
url = req.url,
method = req.method,
body = req.env.spore.payload,
on_headers = function(headers)
for header, value in pairs(req.headers) do
if type(header) == "string" then
headers:add(header, value)
end
end
end,
}, function(res)
result = res
-- Turbo HTTP client uses code instead of status
-- change to status so that Spore can understand
result.status = res.code
coroutine.resume(args.thread)
end)
return coroutine.create(function() coroutine.yield(result) end)
end
end
function ReadestSyncClient:pullChanges(params, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("ReadestHeaders", {})
self.client:enable("ReadestAuth", {})
socketutil:set_timeout(SYNC_TIMEOUTS[1], SYNC_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:pullChanges({
since = params.since,
type = params.type,
book = params.book,
})
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("ReadestSyncClient:pullChanges failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
function ReadestSyncClient:pushChanges(changes, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("ReadestHeaders", {})
self.client:enable("ReadestAuth", {})
socketutil:set_timeout(SYNC_TIMEOUTS[1], SYNC_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:pushChanges(changes or {})
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("ReadestSyncClient:pushChanges failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
return ReadestSyncClient
@@ -0,0 +1,33 @@
{
"base_url": "https://readest.supabase.co/auth/v1/",
"name": "supabase-auth-api",
"methods": {
"sign_in_password": {
"path": "/token?grant_type=password",
"method": "POST",
"required_params": ["email", "password"],
"payload": ["email", "password"],
"expected_status": [200, 400, 401]
},
"refresh_token": {
"path": "/token?grant_type=refresh_token",
"method": "POST",
"required_params": ["refresh_token"],
"payload": ["refresh_token"],
"expected_status": [200, 400, 401, 403]
},
"sign_out": {
"path": "/logout",
"method": "POST",
"required_params": [],
"payload": [],
"expected_status": [204, 401]
},
"get_user": {
"path": "/user",
"method": "GET",
"required_params": [],
"expected_status": [200, 401]
}
}
}
+222
View File
@@ -0,0 +1,222 @@
local UIManager = require("ui/uimanager")
local logger = require("logger")
local socketutil = require("socketutil")
-- Auth operation timeouts
local AUTH_TIMEOUTS = { 5, 10 }
-- Token refresh timeouts
local REFRESH_TIMEOUTS = { 3, 7 }
local SupabaseAuthClient = {
service_spec = nil,
api_key = nil,
}
function SupabaseAuthClient:new(o)
if o == nil then o = {} end
setmetatable(o, self)
self.__index = self
if o.init then o:init() end
return o
end
function SupabaseAuthClient:init()
local Spore = require("Spore")
self.client = Spore.new_from_spec(self.service_spec)
-- Supabase API headers middleware
package.loaded["Spore.Middleware.SupabaseHeaders"] = {}
require("Spore.Middleware.SupabaseHeaders").call = function(args, req)
req.headers["apikey"] = args.api_key
req.headers["content-type"] = "application/json"
req.headers["accept"] = "application/json"
end
-- Supabase Bearer token auth middleware
package.loaded["Spore.Middleware.SupabaseAuth"] = {}
require("Spore.Middleware.SupabaseAuth").call = function(args, req)
if args.access_token then
req.headers["authorization"] = "Bearer " .. args.access_token
end
end
package.loaded["Spore.Middleware.AsyncHTTP"] = {}
require("Spore.Middleware.AsyncHTTP").call = function(args, req)
-- disable async http if Turbo looper is missing
if not UIManager.looper then return end
req:finalize()
local result
require("httpclient"):new():request({
url = req.url,
method = req.method,
body = req.env.spore.payload,
on_headers = function(headers)
for header, value in pairs(req.headers) do
if type(header) == "string" then
headers:add(header, value)
end
end
end,
}, function(res)
result = res
-- Turbo HTTP client uses code instead of status
-- change to status so that Spore can understand
result.status = res.code
coroutine.resume(args.thread)
end)
return coroutine.create(function() coroutine.yield(result) end)
end
end
function SupabaseAuthClient:sign_in_password(email, password)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local ok, res = pcall(function()
return self.client:sign_in_password({
email = email,
password = password,
})
end)
socketutil:reset_timeout()
if ok then
return res.status == 200, res.body
else
logger.dbg("SupabaseAuthClient:sign_in_password failure:", res)
return false, res.body
end
end
function SupabaseAuthClient:sign_in_otp(email, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:sign_in_otp({
email = email,
})
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("SupabaseAuthClient:sign_in_otp failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
function SupabaseAuthClient:verify_otp(email, token, type)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local ok, res = pcall(function()
return self.client:verify_otp({
email = email,
token = token,
type = type or "email",
})
end)
socketutil:reset_timeout()
if ok then
return res.status == 200, res.body
else
logger.dbg("SupabaseAuthClient:verify_otp failure:", res)
return false, res.body
end
end
function SupabaseAuthClient:refresh_token(refresh_token, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
socketutil:set_timeout(REFRESH_TIMEOUTS[1], REFRESH_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:refresh_token({
refresh_token = refresh_token,
})
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("SupabaseAuthClient:refresh_token failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
function SupabaseAuthClient:sign_out(access_token, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
self.client:enable("SupabaseAuth", {
access_token = access_token,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:sign_out()
end)
if ok then
callback(res.status == 204, res.body)
else
logger.dbg("SupabaseAuthClient:sign_out failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
function SupabaseAuthClient:get_user(access_token, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
self.client:enable("SupabaseAuth", {
access_token = access_token,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:get_user()
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("SupabaseAuthClient:get_user failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
return SupabaseAuthClient
+1
View File
@@ -11,6 +11,7 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.9.1",
"@sindresorhus/tsconfig": "^6.0.0",
"caniuse-lite": "^1.0.30001731",
"eslint": "^9.28.0",
"eslint-config-prettier": "^9.1.0",
"husky": "^9.1.6",
+35 -8
View File
@@ -17,6 +17,9 @@ importers:
'@sindresorhus/tsconfig':
specifier: ^6.0.0
version: 6.0.0
caniuse-lite:
specifier: ^1.0.30001731
version: 1.0.30001731
eslint:
specifier: ^9.28.0
version: 9.28.0(jiti@1.21.6)
@@ -203,6 +206,9 @@ importers:
tinycolor2:
specifier: ^1.6.0
version: 1.6.0
uuid:
specifier: ^11.1.0
version: 11.1.0
zod:
specifier: ^4.0.8
version: 4.0.10
@@ -252,6 +258,9 @@ importers:
'@types/tinycolor2':
specifier: ^1.4.6
version: 1.4.6
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@vitejs/plugin-react':
specifier: ^4.7.0
version: 4.7.0(vite@7.0.6(@types/node@22.15.31)(jiti@1.21.6)(terser@5.43.1)(yaml@2.7.0))
@@ -3117,6 +3126,9 @@ packages:
'@types/node@22.16.5':
resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==}
'@types/node@22.17.0':
resolution: {integrity: sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==}
'@types/phoenix@1.6.6':
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
@@ -3154,6 +3166,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
@@ -3641,12 +3656,12 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001684:
resolution: {integrity: sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==}
caniuse-lite@1.0.30001695:
resolution: {integrity: sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==}
caniuse-lite@1.0.30001731:
resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
chai@5.2.1:
resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==}
engines: {node: '>=18'}
@@ -6513,6 +6528,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
@@ -10800,6 +10819,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@22.17.0':
dependencies:
undici-types: 6.21.0
'@types/phoenix@1.6.6': {}
'@types/prop-types@15.7.13': {}
@@ -10834,6 +10857,8 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@types/uuid@10.0.0': {}
'@types/uuid@9.0.8': {}
'@types/ws@8.18.1':
@@ -11266,7 +11291,7 @@ snapshots:
autoprefixer@10.4.20(postcss@8.4.49):
dependencies:
browserslist: 4.24.2
caniuse-lite: 1.0.30001684
caniuse-lite: 1.0.30001695
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
@@ -11359,7 +11384,7 @@ snapshots:
browserslist@4.24.2:
dependencies:
caniuse-lite: 1.0.30001684
caniuse-lite: 1.0.30001695
electron-to-chromium: 1.5.67
node-releases: 2.0.18
update-browserslist-db: 1.1.1(browserslist@4.24.2)
@@ -11410,10 +11435,10 @@ snapshots:
camelcase@5.3.1: {}
caniuse-lite@1.0.30001684: {}
caniuse-lite@1.0.30001695: {}
caniuse-lite@1.0.30001731: {}
chai@5.2.1:
dependencies:
assertion-error: 2.0.1
@@ -12842,7 +12867,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 22.16.5
'@types/node': 22.17.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -14463,6 +14488,8 @@ snapshots:
utils-merge@1.0.1: {}
uuid@11.1.0: {}
uuid@9.0.1: {}
v8-compile-cache-lib@3.0.1: